diff --git a/release-notes/VERSION b/release-notes/VERSION index 319d86d1b6..60e10fc04e 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -19,6 +19,9 @@ JSON library. #679: Number parsing should fail for trailing dot (period) (fix by @cowtowncoder, w/ Claude code) +#707: Add `JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS` for JSON5-style hexadecimal + integer literals (`0x` / `0X`, optional sign) + (implementation by @seonwooj0810) #1211: Add `JsonParser.willInternPropertyNames()` to check whether property name interning is enabled (contributed by Max P) diff --git a/src/main/java/tools/jackson/core/base/ParserBase.java b/src/main/java/tools/jackson/core/base/ParserBase.java index 23dc3327ab..35836564e0 100644 --- a/src/main/java/tools/jackson/core/base/ParserBase.java +++ b/src/main/java/tools/jackson/core/base/ParserBase.java @@ -398,7 +398,10 @@ protected final JsonToken reset(boolean negative, int intLen, int fractLen, int return resetFloat(negative, intLen, fractLen, expLen); } - protected final JsonToken resetInt(boolean negative, int intLen) + // NOTE: was `final` before 3.2; relaxed so that `JsonParserBase` can + // override to clear hex-specific state on integer reset (the sibling + // `resetFloat` / `resetAsNaN` remain `final`). + protected JsonToken resetInt(boolean negative, int intLen) throws JacksonException { // May throw StreamConstraintsException: diff --git a/src/main/java/tools/jackson/core/io/BigIntegerParser.java b/src/main/java/tools/jackson/core/io/BigIntegerParser.java index d3bc5fe3b0..608a88a111 100644 --- a/src/main/java/tools/jackson/core/io/BigIntegerParser.java +++ b/src/main/java/tools/jackson/core/io/BigIntegerParser.java @@ -36,4 +36,21 @@ public static BigInteger parseWithFastParser(final String valueStr, final int ra ", reason: " + nfe.getMessage()); } } + + /** + * @since 3.2 + */ + public static BigInteger parseWithFastParser(final char[] ch, final int offset, + final int length, final int radix) { + try { + return JavaBigIntegerParser.parseBigInteger(ch, offset, length, radix); + } catch (NumberFormatException nfe) { + final String reportNum = length <= MAX_CHARS_TO_REPORT + ? new String(ch, offset, length) + : new String(ch, offset, MAX_CHARS_TO_REPORT) + " [truncated]"; + throw new NumberFormatException("Value \"" + reportNum + + "\" cannot be represented as `java.math.BigInteger` with radix " + radix + + ", reason: " + nfe.getMessage()); + } + } } diff --git a/src/main/java/tools/jackson/core/io/NumberInput.java b/src/main/java/tools/jackson/core/io/NumberInput.java index 446d354d13..ebad3b1d23 100644 --- a/src/main/java/tools/jackson/core/io/NumberInput.java +++ b/src/main/java/tools/jackson/core/io/NumberInput.java @@ -589,6 +589,30 @@ public static BigInteger parseBigIntegerWithRadix(final String s, final int radi return new BigInteger(s, radix); } + /** + * Parse a {@link BigInteger} from a {@code char[]} slice. When + * {@code useFastParser} is {@code true} the slice is handed to + * {@code FastDoubleParser} directly so no intermediate {@link String} is + * allocated; otherwise the JDK constructor is used (which requires a + * temporary String). + * + * @param ch char array containing the digits to parse + * @param offset offset of the first digit in {@code ch} + * @param length number of digits to parse + * @param radix radix to parse with + * @param useFastParser whether to use {@code FastDoubleParser} (true) or the JDK default (false) + * @return a BigInteger + * @throws NumberFormatException if the char slice cannot be represented by a BigInteger with the given radix + * @since 3.2 + */ + public static BigInteger parseBigIntegerWithRadix(final char[] ch, final int offset, + final int length, final int radix, final boolean useFastParser) throws NumberFormatException { + if (useFastParser) { + return BigIntegerParser.parseWithFastParser(ch, offset, length, radix); + } + return new BigInteger(new String(ch, offset, length), radix); + } + /** * Method called to check whether given pattern looks like a valid Java * Number (which is bit looser definition than valid JSON Number). diff --git a/src/main/java/tools/jackson/core/json/JsonParserBase.java b/src/main/java/tools/jackson/core/json/JsonParserBase.java index 29e6b5fceb..f5d38b84f0 100644 --- a/src/main/java/tools/jackson/core/json/JsonParserBase.java +++ b/src/main/java/tools/jackson/core/json/JsonParserBase.java @@ -1,9 +1,12 @@ package tools.jackson.core.json; +import java.math.BigInteger; + import tools.jackson.core.*; import tools.jackson.core.base.ParserBase; import tools.jackson.core.exc.InputCoercionException; import tools.jackson.core.exc.StreamReadException; +import tools.jackson.core.io.CharTypes; import tools.jackson.core.io.IOContext; import tools.jackson.core.io.NumberInput; import tools.jackson.core.util.JacksonFeatureSet; @@ -49,6 +52,19 @@ public abstract class JsonParserBase */ protected JsonToken _nextToken; + /** + * Marker for integer values read using JSON5 hexadecimal notation + * ({@code 0x} / {@code 0X} prefix), enabled via + * {@link JsonReadFeature#ALLOW_HEXADECIMAL_NUMBERS}. + * When {@code true}, the textual representation buffered for the current + * token is the original hex literal (including any sign and the + * {@code 0x}/{@code 0X} prefix) and {@link #_intLength} records the + * number of hexadecimal digits (excluding sign and prefix). + * + * @since 3.2 + */ + protected boolean _numberIsHex; + /* /********************************************************************** /* Helper buffer recycling @@ -186,12 +202,52 @@ protected void createChildObjectContext(final int lineNr, final int colNr) throw /********************************************************************** */ + // Overridden to also clear the JSON-only `_numberIsHex` flag, so a + // subsequent regular integer is not mis-decoded as hex. Hex literals go + // through `resetIntHex` instead, which sets the flag. + @Override + protected JsonToken resetInt(boolean negative, int intLen) + throws JacksonException + { + _numberIsHex = false; + return super.resetInt(negative, intLen); + } + + /** + * Variant of {@link #resetInt} used for integer values read in JSON5 + * hexadecimal notation ({@code 0x...}). {@code hexDigitLen} is the + * number of hexadecimal digits (excluding sign and {@code 0x}/{@code 0X} + * prefix); the textual representation buffered by the caller is expected + * to contain the original literal including sign and prefix. + * + * @since 3.2 + */ + protected final JsonToken resetIntHex(boolean negative, int hexDigitLen) + throws JacksonException + { + // May throw StreamConstraintsException: + _streamReadConstraints.validateIntegerLength(hexDigitLen); + _numberNegative = negative; + _numberIsNaN = false; + _numberIsHex = true; + _intLength = hexDigitLen; + _fractLength = 0; + _expLength = 0; + _numTypesValid = NR_UNKNOWN; // to force decoding + _numberString = null; + return JsonToken.VALUE_NUMBER_INT; + } + @Override protected void _parseNumericValue(int expType) throws JacksonException, InputCoercionException { // Int or float? if (_currToken == JsonToken.VALUE_NUMBER_INT) { + if (_numberIsHex) { + _parseHexInt(expType); + return; + } int len = _intLength; // First: optimization for simple int if (len <= 9) { @@ -250,7 +306,9 @@ protected int _parseIntValue() throws JacksonException { // Inlined variant of: _parseNumericValue(NR_INT) if (_currToken == JsonToken.VALUE_NUMBER_INT) { - if (_intLength <= 9) { + // Hex integers go through the generic path so the base-16 decode is + // applied (the base-10 fast path below would mis-read the literal): + if (_intLength <= 9 && !_numberIsHex) { int i = _textBuffer.contentsAsInt(_numberNegative); _numberInt = i; _numTypesValid = NR_INT; @@ -297,6 +355,106 @@ private void _parseSlowFloat(int expType) throws JacksonException } } + /** + * Decode a JSON5 hexadecimal integer that was buffered as the original + * textual literal (sign + {@code 0x}/{@code 0X} prefix + hex digits). + * {@link #_intLength} holds the count of hex digits. + * + * @since 3.2 + */ + private void _parseHexInt(int expType) throws JacksonException + { + final int hexLen = _intLength; + final char[] buf = _textBuffer.getTextBuffer(); + // Locate the first hex digit: skip optional sign and "0x" / "0X" prefix + int idx = _textBuffer.getTextOffset(); + final char first = buf[idx]; + if (first == '-' || first == '+') { + ++idx; + } + idx += 2; // skip "0x" / "0X" + + // Up to 7 hex digits always fit in a positive signed int (<= 0x0FFFFFFF). + // 8 hex digits may overflow signed int (e.g. 0x80000000), so we defer to + // the long path which handles range checks uniformly. + if (hexLen <= 7) { + int v = 0; + for (int i = 0; i < hexLen; ++i) { + v = (v << 4) | CharTypes.charToHex(buf[idx + i]); + } + _numberInt = _numberNegative ? -v : v; + _numTypesValid = NR_INT; + return; + } + // 9..15 hex digits always fit in a positive long (63 bits used at most) + if (hexLen <= 15) { + long v = 0L; + for (int i = 0; i < hexLen; ++i) { + v = (v << 4) | CharTypes.charToHex(buf[idx + i]); + } + _numberLong = _numberNegative ? -v : v; + _numTypesValid = NR_LONG; + return; + } + // 16 hex digits: may or may not fit in signed long, depending on top bit + if (hexLen == 16) { + int topNibble = CharTypes.charToHex(buf[idx]); + if (topNibble < 0x8) { // fits in positive signed long + long v = topNibble; + for (int i = 1; i < 16; ++i) { + v = (v << 4) | CharTypes.charToHex(buf[idx + i]); + } + _numberLong = _numberNegative ? -v : v; + _numTypesValid = NR_LONG; + return; + } + // else fall through to BigInteger path + } + // Larger values -> BigInteger. We must eagerly decode here (the lazy + // base-10 path via _numberString would mis-read hex digits). Pass the + // char[] slice directly so the fast path avoids an intermediate String. + BigInteger bi = NumberInput.parseBigIntegerWithRadix(buf, idx, hexLen, 16, + isEnabled(StreamReadFeature.USE_FAST_BIG_NUMBER_PARSER)); + if (_numberNegative) { + bi = bi.negate(); + } + _numberBigInt = bi; + _numberString = null; + _numTypesValid = NR_BIGINT; + if ((expType == NR_INT) || (expType == NR_LONG)) { + // Force the overflow path to surface a meaningful error + _reportTooLongIntegral(expType, _textBuffer.contentsAsString()); + } + } + + /** + * Standard error message used by all JSON parser variants when a + * {@code 0x}/{@code 0X} hex prefix is not followed by any hex digit. + * + * @since 3.2 + */ + protected static String _hexPrefixNotFollowedMessage(char prefixChar) { + return "Hexadecimal number prefix '0" + prefixChar + + "' must be followed by at least one hex digit (0-9, a-f, A-F)"; + } + + /** + * Called after seeing the {@code 'x'} or {@code 'X'} that follows a leading + * {@code '0'} in a number literal. Returns silently if + * {@link JsonReadFeature#ALLOW_HEXADECIMAL_NUMBERS} is enabled; otherwise + * throws a {@link StreamReadException} naming the feature that must be + * enabled, so the user gets a specific actionable error instead of a + * generic "unexpected character". + * + * @since 3.2 + */ + protected void _checkHexNumbersAllowed(int prefixChar) throws StreamReadException { + if (!isEnabled(JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS)) { + _reportUnexpectedChar(prefixChar, + "hexadecimal number literals require enabling `JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS`"); + } + } + private void _parseSlowInt(int expType) throws JacksonException { final String numStr = _textBuffer.contentsAsString(); diff --git a/src/main/java/tools/jackson/core/json/JsonReadFeature.java b/src/main/java/tools/jackson/core/json/JsonReadFeature.java index 191ce8db28..258c5c27d9 100644 --- a/src/main/java/tools/jackson/core/json/JsonReadFeature.java +++ b/src/main/java/tools/jackson/core/json/JsonReadFeature.java @@ -104,6 +104,38 @@ public enum JsonReadFeature // // // Support for non-standard data format constructs: number representations + /** + * Feature that determines whether parser will allow + * JSON integer numbers to be expressed in hexadecimal + * notation as defined by the + * JSON5 specification: + * a {@code 0x} or {@code 0X} prefix followed by one or more + * hexadecimal digits ({@code [0-9a-fA-F]}), optionally preceded + * by a single {@code +} or {@code -} sign + * (with {@link #ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS} additionally + * required for the {@code +} variant). + * When enabled, tokens such as {@code 0xC0FFEE} or {@code -0x10} are + * accepted as {@link JsonToken#VALUE_NUMBER_INT}. The textual + * representation returned by {@link JsonParser#getString()} preserves + * the original literal (including the {@code 0x} / {@code 0X} prefix + * and any sign), while numeric accessors such as + * {@link JsonParser#getIntValue()}, {@link JsonParser#getLongValue()} + * and {@link JsonParser#getBigIntegerValue()} return the decoded value. + *
+ * This feature is independent of + * {@link #ALLOW_LEADING_ZEROS_FOR_NUMBERS}: leading zeros in the + * hexadecimal digit sequence (for example {@code 0x007F}) are always + * permitted when this feature is enabled, regardless of the state of + * {@code ALLOW_LEADING_ZEROS_FOR_NUMBERS}, since the JSON5 grammar + * allows them. + *
+ * Since JSON specification does not allow hexadecimal numbers, + * this is a non-standard feature, and disabled by default. + * + * @since 3.2 + */ + ALLOW_HEXADECIMAL_NUMBERS(false), + /** * Feature that determines whether parser will allow * JSON decimal numbers to start with a decimal point diff --git a/src/main/java/tools/jackson/core/json/ReaderBasedJsonParser.java b/src/main/java/tools/jackson/core/json/ReaderBasedJsonParser.java index a8bee6044f..94cf2320de 100644 --- a/src/main/java/tools/jackson/core/json/ReaderBasedJsonParser.java +++ b/src/main/java/tools/jackson/core/json/ReaderBasedJsonParser.java @@ -1581,6 +1581,17 @@ private final JsonToken _parseNumber2(boolean neg, int startPtr) throws JacksonE char c = (_inputPtr < _inputEnd) ? _inputBuffer[_inputPtr++] : getNextChar("No digit following sign", JsonToken.VALUE_NUMBER_INT); if (c == '0') { + // [core#707]: JSON5 hexadecimal literal ('0x' / '0X')? + // Must be checked BEFORE _verifyNoLeadingZeroes(): leading zeros are + // valid in hex regardless of ALLOW_LEADING_ZEROS_FOR_NUMBERS. + if (_inputPtr < _inputEnd || _loadMore()) { + char peek = _inputBuffer[_inputPtr]; + if (peek == 'x' || peek == 'X') { + ++_inputPtr; + _checkHexNumbersAllowed(peek); + return _finishHexNumber(neg, outBuf, outPtr, peek); + } + } c = _verifyNoLeadingZeroes(); } boolean eof = false; @@ -1708,6 +1719,68 @@ private final JsonToken _parseNumber2(boolean neg, int startPtr) throws JacksonE return resetFloat(neg, intLen, fractLen, expLen); } + // [core#707] Finish parsing a JSON5 hexadecimal integer literal once + // '0' + 'x'/'X' has been recognized. The current text buffer already + // contains the optional sign and the leading '0'; we append 'x'/'X' and + // then all hex digits, then validate proper termination. + // + // @since 3.2 + private final JsonToken _finishHexNumber(boolean neg, + char[] outBuf, int outPtr, char prefixChar) + throws JacksonException + { + // Append the '0' and the 'x'/'X' + if (outPtr >= outBuf.length) { + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = '0'; + if (outPtr >= outBuf.length) { + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = prefixChar; + + int hexLen = 0; + boolean eof = false; + char c = CHAR_NULL; + + hex_loop: + while (true) { + if (_inputPtr >= _inputEnd && !_loadMore()) { + eof = true; + break hex_loop; + } + c = _inputBuffer[_inputPtr++]; + if (CharTypes.charToHex(c) < 0) { + break hex_loop; + } + ++hexLen; + if (outPtr >= outBuf.length) { + // Validate accumulated length at every segment boundary so that a + // pathological hex literal (e.g. millions of digits) is rejected + // early rather than after full buffering. + _streamReadConstraints.validateIntegerLength(hexLen); + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = c; + } + + if (hexLen == 0) { + return _reportUnexpectedNumberChar(c, _hexPrefixNotFollowedMessage(prefixChar)); + } + + if (!eof) { + --_inputPtr; // push back the terminating non-hex char + if (_streamReadContext.inRoot()) { + _verifyRootSpace(c); + } + } + _textBuffer.setCurrentLength(outPtr); + return resetIntHex(neg, hexLen); + } + // Method called when we have seen one zero, and want to ensure // it is not followed by another private final char _verifyNoLeadingZeroes() throws JacksonException diff --git a/src/main/java/tools/jackson/core/json/UTF8DataInputJsonParser.java b/src/main/java/tools/jackson/core/json/UTF8DataInputJsonParser.java index 90bf841835..778342b049 100644 --- a/src/main/java/tools/jackson/core/json/UTF8DataInputJsonParser.java +++ b/src/main/java/tools/jackson/core/json/UTF8DataInputJsonParser.java @@ -1064,7 +1064,9 @@ protected JsonToken _parseUnsignedNumber(int c) throws IOException if (c <= INT_9 && c >= INT_0) { // skip if followed by digit outPtr = 0; } else if (c == 'x' || c == 'X') { - return _handleInvalidNumberStart(c, false); + // [core#707] JSON5 hexadecimal literal? + _checkHexNumbersAllowed(c); + return _finishHexNumber(false, outBuf, 0, c); } else { outBuf[0] = '0'; outPtr = 1; @@ -1123,6 +1125,13 @@ private final JsonToken _parseSignedNumber(boolean negative) throws IOException // One special case: if first char is 0 need to check no leading zeroes if (c == INT_0) { c = _handleLeadingZeroes(); + // [core#707] JSON5 hexadecimal literal with sign? + if (c == 'x' || c == 'X') { + _checkHexNumbersAllowed(c); + // outBuf currently holds [sign, '0']; the helper re-appends + // '0' itself, so rewind outPtr to just after the sign. + return _finishHexNumber(negative, outBuf, 1, c); + } } else if (c == INT_PERIOD) { return _parseFloatThatStartsWithPeriod(negative, true); } else { @@ -1160,6 +1169,52 @@ private final JsonToken _parseSignedNumber(boolean negative) throws IOException return resetInt(negative, intLen); } + // [core#707] Finish parsing a JSON5 hexadecimal integer literal. On entry the + // optional sign (if any) is already in outBuf at indices [0..outPtr-1] and + // the 'x'/'X' has already been consumed from the underlying DataInput. + // We append '0' + prefix char + all hex digits. + // + // @since 3.2 + private final JsonToken _finishHexNumber(boolean neg, char[] outBuf, int outPtr, + int prefixChar) throws IOException + { + if (outPtr >= outBuf.length) { + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = '0'; + if (outPtr >= outBuf.length) { + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = (char) prefixChar; + + int hexLen = 0; + int c = readUnsignedByte(); + while (CharTypes.charToHex(c) >= 0) { + ++hexLen; + if (outPtr >= outBuf.length) { + // Validate accumulated length at every segment boundary so that a + // pathological hex literal (e.g. millions of digits) is rejected + // early rather than after full buffering. + _streamReadConstraints.validateIntegerLength(hexLen); + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = (char) c; + c = readUnsignedByte(); + } + if (hexLen == 0) { + return _reportUnexpectedNumberChar(c, _hexPrefixNotFollowedMessage((char) prefixChar)); + } + _textBuffer.setCurrentLength(outPtr); + _nextByte = c; + if (_streamReadContext.inRoot()) { + _verifyRootSpace(); + } + return resetIntHex(neg, hexLen); + } + /** * Method called when we have seen one zero, and want to ensure * it is not followed by another, or, if leading zeroes allowed, diff --git a/src/main/java/tools/jackson/core/json/UTF8StreamJsonParser.java b/src/main/java/tools/jackson/core/json/UTF8StreamJsonParser.java index 3821334fec..0794e2ad9e 100644 --- a/src/main/java/tools/jackson/core/json/UTF8StreamJsonParser.java +++ b/src/main/java/tools/jackson/core/json/UTF8StreamJsonParser.java @@ -1819,6 +1819,17 @@ protected JsonToken _parseUnsignedNumber(int c) throws JacksonException char[] outBuf = _textBuffer.emptyAndGetCurrentSegment(); // One special case: if first char is 0, must not be followed by a digit if (c == INT_0) { + // [core#707] JSON5 hexadecimal literal ('0x' / '0X')? + // Must be checked BEFORE _verifyNoLeadingZeroes(): leading zeros in + // hex digits are valid regardless of ALLOW_LEADING_ZEROS_FOR_NUMBERS. + if (_inputPtr < _inputEnd || _loadMore()) { + int peek = _inputBuffer[_inputPtr] & 0xFF; + if (peek == 'x' || peek == 'X') { + ++_inputPtr; + _checkHexNumbersAllowed(peek); + return _finishHexNumber(false, outBuf, 0, peek); + } + } c = _verifyNoLeadingZeroes(); } // Ok: we can first just add digit we saw first: @@ -1874,6 +1885,15 @@ private final JsonToken _parseSignedNumber(boolean negative) throws JacksonExcep } return _handleInvalidNumberStart(c, negative, true); } + // [core#707] JSON5 hexadecimal literal ('0x' / '0X') with optional sign? + if (_inputPtr < _inputEnd || _loadMore()) { + int peek = _inputBuffer[_inputPtr] & 0xFF; + if (peek == 'x' || peek == 'X') { + ++_inputPtr; + _checkHexNumbersAllowed(peek); + return _finishHexNumber(negative, outBuf, outPtr, peek); + } + } c = _verifyNoLeadingZeroes(); } else if (c > INT_9) { return _handleInvalidNumberStart(c, negative, true); @@ -1951,6 +1971,68 @@ private final JsonToken _parseNumber2(char[] outBuf, int outPtr, boolean negativ } + // [core#707] Finish parsing a JSON5 hexadecimal integer literal. On entry the + // optional sign (if any) is already in outBuf at indices [0..outPtr-1], and + // we have seen '0' followed by 'x'/'X' . + // We append '0' then the prefix char then all hex digits. + // + // @since 3.2 + private final JsonToken _finishHexNumber(boolean neg, char[] outBuf, int outPtr, + int prefixChar) + throws JacksonException + { + // Prepend "0x" prefix + if (outPtr >= outBuf.length) { + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = '0'; + if (outPtr >= outBuf.length) { + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = (char) prefixChar; + + int hexLen = 0; + int c = 0; + boolean eof = false; + + hex_loop: + while (true) { + if (_inputPtr >= _inputEnd && !_loadMore()) { + eof = true; + break hex_loop; + } + c = _inputBuffer[_inputPtr++] & 0xFF; + if (CharTypes.charToHex(c) < 0) { + break hex_loop; + } + ++hexLen; + if (outPtr >= outBuf.length) { + // Validate accumulated length at every segment boundary so that a + // pathological hex literal (e.g. millions of digits) is rejected + // early rather than after full buffering. + _streamReadConstraints.validateIntegerLength(hexLen); + outBuf = _textBuffer.finishCurrentSegment(); + outPtr = 0; + } + outBuf[outPtr++] = (char) c; + } + + if (hexLen == 0) { + return _reportUnexpectedNumberChar(c, _hexPrefixNotFollowedMessage((char) prefixChar)); + } + + if (!eof) { + --_inputPtr; // push back the terminating non-hex char + if (_streamReadContext.inRoot()) { + _verifyRootSpace(c); + } + } + _textBuffer.setCurrentLength(outPtr); + return resetIntHex(neg, hexLen); + } + // Method called when we have seen one zero, and want to ensure // it is not followed by another private final int _verifyNoLeadingZeroes() throws JacksonException diff --git a/src/main/java/tools/jackson/core/json/async/NonBlockingJsonParserBase.java b/src/main/java/tools/jackson/core/json/async/NonBlockingJsonParserBase.java index 1a3d12ddb8..64c78f8f76 100644 --- a/src/main/java/tools/jackson/core/json/async/NonBlockingJsonParserBase.java +++ b/src/main/java/tools/jackson/core/json/async/NonBlockingJsonParserBase.java @@ -116,6 +116,12 @@ public abstract class NonBlockingJsonParserBase // resume correctly retains the leading '+' character. protected final static int MINOR_NUMBER_PLUSZERO = 33; + // [core#707] JSON5 hexadecimal literal states: + // - HEX_PREFIX: textBuffer holds optional sign + '0' + 'x'/'X'; awaiting first hex digit + // - HEX_DIGITS: textBuffer holds sign + '0x'/'0X' + at least one hex digit; awaiting more + protected final static int MINOR_NUMBER_HEX_PREFIX = 34; + protected final static int MINOR_NUMBER_HEX_DIGITS = 35; + protected final static int MINOR_VALUE_STRING = 40; protected final static int MINOR_VALUE_STRING_ESCAPE = 41; protected final static int MINOR_VALUE_STRING_UTF8_2 = 42; diff --git a/src/main/java/tools/jackson/core/json/async/NonBlockingUtf8JsonParserBase.java b/src/main/java/tools/jackson/core/json/async/NonBlockingUtf8JsonParserBase.java index 5cfc608180..1e777b0452 100644 --- a/src/main/java/tools/jackson/core/json/async/NonBlockingUtf8JsonParserBase.java +++ b/src/main/java/tools/jackson/core/json/async/NonBlockingUtf8JsonParserBase.java @@ -256,6 +256,12 @@ protected final JsonToken _finishToken() throws JacksonException case MINOR_NUMBER_EXPONENT_DIGITS: return _finishFloatExponent(false, getNextUnsignedByteFromBuffer()); + // [core#707] JSON5 hex resumption + case MINOR_NUMBER_HEX_PREFIX: + return _finishHexDigits(true); + case MINOR_NUMBER_HEX_DIGITS: + return _finishHexDigits(false); + case MINOR_VALUE_STRING: return _finishRegularString(); case MINOR_VALUE_STRING_UTF8_2: @@ -386,6 +392,14 @@ protected final JsonToken _finishTokenWithEOF() throws JacksonException case MINOR_NUMBER_EXPONENT_MARKER: _reportInvalidEOF(": was expecting fraction after exponent marker", JsonToken.VALUE_NUMBER_FLOAT); + // [core#707] JSON5 hex EOF handling + case MINOR_NUMBER_HEX_PREFIX: + _reportInvalidEOF(": expected at least one hexadecimal digit after '0x'/'0X' prefix", + JsonToken.VALUE_NUMBER_INT); + case MINOR_NUMBER_HEX_DIGITS: + // Suspended with at least one hex digit accumulated; finalize. + return _completeHexNumber(); + // How about comments? // Inside C-comments; not legal @@ -1514,6 +1528,12 @@ protected JsonToken _startNumberLeadingZero() throws JacksonException outBuf[0] = '0'; return _startFloat(outBuf, 1, ch); } + // [core#707] JSON5 hexadecimal literal? + if (ch == 'x' || ch == 'X') { + _inputPtr = ptr; // consume the 'x'/'X' + _checkHexNumbersAllowed(ch); + return _startHexNumber(false, (char) ch); + } // Ok; unfortunately we have closing bracket/curly that are valid so need // (colon not possible since this is within value, not after key) // @@ -1611,6 +1631,11 @@ protected JsonToken _finishNumberLeadingZeroes() throws JacksonException _intLength = 1; return _startFloat(outBuf, 1, ch); } + // [core#707] JSON5 hexadecimal literal? + if (ch == 'x' || ch == 'X') { + _checkHexNumbersAllowed(ch); + return _startHexNumber(false, (char) ch); + } // Ok; unfortunately we have closing bracket/curly that are valid so need // (colon not possible since this is within value, not after key) // @@ -1674,6 +1699,11 @@ protected JsonToken _finishNumberLeadingPosNegZeroes(final boolean negative) thr _intLength = 1; return _startFloat(outBuf, 2, ch); } + // [core#707] JSON5 hexadecimal literal? + if (ch == 'x' || ch == 'X') { + _checkHexNumbersAllowed(ch); + return _startHexNumberWithSign(negative, (char) ch); + } // Ok; unfortunately we have closing bracket/curly that are valid so need // (colon not possible since this is within value, not after key) // @@ -1702,6 +1732,89 @@ protected JsonToken _finishNumberLeadingPosNegZeroes(final boolean negative) thr } } + // [core#707] JSON5 hex helpers - start collecting digits after detecting + // the '0x'/'0X' prefix. Two entry points differ only by whether a sign char + // ('-' / '+') needs to be prepended to the buffered literal. + // + // @since 3.2 + protected JsonToken _startHexNumber(boolean negative, char prefixChar) throws JacksonException { + _numberNegative = negative; + char[] outBuf = _textBuffer.emptyAndGetCurrentSegment(); + int outPtr = 0; + if (negative) { + outBuf[outPtr++] = '-'; + } + outBuf[outPtr++] = '0'; + outBuf[outPtr++] = prefixChar; + _textBuffer.setCurrentLength(outPtr); + return _finishHexDigits(true); + } + + protected JsonToken _startHexNumberWithSign(boolean negative, char prefixChar) throws JacksonException { + _numberNegative = negative; + char[] outBuf = _textBuffer.emptyAndGetCurrentSegment(); + outBuf[0] = negative ? '-' : '+'; + outBuf[1] = '0'; + outBuf[2] = prefixChar; + _textBuffer.setCurrentLength(3); + return _finishHexDigits(true); + } + + protected JsonToken _finishHexDigits(boolean requireFirst) throws JacksonException { + char[] outBuf = _textBuffer.getBufferWithoutReset(); + int outPtr = _textBuffer.getCurrentSegmentSize(); + // Sign-prefixed buffers carry '+'/'-' at index 0, so prefix length is 3; + // otherwise it is 2 ("0x"/"0X"). + final boolean hasSignChar = (outBuf[0] == '-' || outBuf[0] == '+'); + final int prefixLen = hasSignChar ? 3 : 2; + + while (true) { + if (_inputPtr >= _inputEnd) { + _minorState = requireFirst ? MINOR_NUMBER_HEX_PREFIX : MINOR_NUMBER_HEX_DIGITS; + _textBuffer.setCurrentLength(outPtr); + return _updateTokenToNA(); + } + int ch = getByteFromBuffer(_inputPtr) & 0xFF; + if (CharTypes.charToHex(ch) < 0) { + if (requireFirst) { + return _reportUnexpectedNumberChar(ch, + _hexPrefixNotFollowedMessage(outBuf[prefixLen - 1])); + } + break; + } + ++_inputPtr; + if (outPtr >= outBuf.length) { + // Validate accumulated digit length at every segment boundary so + // that a pathological hex literal (e.g. millions of digits) is + // rejected early rather than after full buffering. + _streamReadConstraints.validateIntegerLength(outPtr - prefixLen); + outBuf = _textBuffer.expandCurrentSegment(); + } + outBuf[outPtr++] = (char) ch; + requireFirst = false; + } + _textBuffer.setCurrentLength(outPtr); + final int hexLen = outPtr - prefixLen; + // As per #105, need separating space between root values; check here. + // Note: _inputPtr currently points AT the terminator (we did not consume it). + if (_streamReadContext.inRoot()) { + _verifyRootSpace(getByteFromBuffer(_inputPtr) & 0xFF); + } + resetIntHex(_numberNegative, hexLen); + return _valueComplete(JsonToken.VALUE_NUMBER_INT); + } + + // Called from _finishTokenWithEOF when input ends while collecting hex digits. + private JsonToken _completeHexNumber() throws JacksonException { + char[] outBuf = _textBuffer.getBufferWithoutReset(); + int outPtr = _textBuffer.getCurrentSegmentSize(); + final boolean hasSignChar = (outBuf[0] == '-' || outBuf[0] == '+'); + final int prefixLen = hasSignChar ? 3 : 2; + final int hexLen = outPtr - prefixLen; + resetIntHex(_numberNegative, hexLen); + return _valueComplete(JsonToken.VALUE_NUMBER_INT); + } + protected JsonToken _finishNumberIntegralPart(char[] outBuf, int outPtr) throws JacksonException { int negMod = _numberNegative ? -1 : 0; int ch; diff --git a/src/test/java/tools/jackson/core/unittest/io/BigIntegerParserTest.java b/src/test/java/tools/jackson/core/unittest/io/BigIntegerParserTest.java index 863baffca0..506d205d6c 100644 --- a/src/test/java/tools/jackson/core/unittest/io/BigIntegerParserTest.java +++ b/src/test/java/tools/jackson/core/unittest/io/BigIntegerParserTest.java @@ -1,10 +1,13 @@ package tools.jackson.core.unittest.io; +import java.math.BigInteger; + import org.junit.jupiter.api.Test; import tools.jackson.core.io.BigIntegerParser; import tools.jackson.core.unittest.JacksonCoreTestBase; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -46,6 +49,29 @@ void longStringFastParseBigIntegerRadix() { } } + @Test + void fastParseBigIntegerCharArrayHexSlice() { + // Decode 0xDEADBEEFCAFEBABE0123 from within a larger buffer to verify + // that offset/length are honored and no intermediate String is required. + char[] buf = ("xx" + "DEADBEEFCAFEBABE0123" + "yy").toCharArray(); + BigInteger actual = BigIntegerParser.parseWithFastParser(buf, 2, 20, 16); + assertEquals(new BigInteger("DEADBEEFCAFEBABE0123", 16), actual); + } + + @Test + void longCharArrayFastParseBigIntegerRadix() { + char[] buf = genLongString().toCharArray(); + try { + BigIntegerParser.parseWithFastParser(buf, 0, buf.length, 8); + fail("expected NumberFormatException"); + } catch (NumberFormatException nfe) { + assertTrue(nfe.getMessage().startsWith("Value \"AAAAA"), "exception message starts as expected?"); + assertTrue(nfe.getMessage().contains("truncated"), "exception message value contains: truncated"); + assertTrue(nfe.getMessage().contains("radix 8"), "exception message value contains: radix 8"); + assertTrue(nfe.getMessage().contains("BigInteger"), "exception message value contains: BigInteger"); + } + } + static String genLongString() { final int len = 1500; final StringBuilder sb = new StringBuilder(len); diff --git a/src/test/java/tools/jackson/core/unittest/io/NumberInputTest.java b/src/test/java/tools/jackson/core/unittest/io/NumberInputTest.java index 18e6839b5d..938d3dd7ef 100644 --- a/src/test/java/tools/jackson/core/unittest/io/NumberInputTest.java +++ b/src/test/java/tools/jackson/core/unittest/io/NumberInputTest.java @@ -64,6 +64,61 @@ void bigIntegerWithRadix() assertEquals(expected, NumberInput.parseBigIntegerWithRadix(val, radix, false)); } + @Test + void bigIntegerWithRadixFromCharArray() + { + final int radix = 16; + final BigInteger expected = new BigInteger("1ABCDEF", radix); + + // 1) offset=0, length spans the entire array. + char[] exact = "1ABCDEF".toCharArray(); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(exact, 0, exact.length, radix, true)); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(exact, 0, exact.length, radix, false)); + + // 2) offset=0, length deliberately shorter than the array (ignore trailing chars). + char[] trailing = "1ABCDEFzz".toCharArray(); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(trailing, 0, 7, radix, true)); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(trailing, 0, 7, radix, false)); + + // 3) offset>0, length skips both leading and trailing chars. + char[] padded = ("xx" + "1ABCDEF" + "yy").toCharArray(); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(padded, 2, 7, radix, true)); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(padded, 2, 7, radix, false)); + } + + @Test + void bigIntegerWithRadixFromCharArrayNegative() + { + final int radix = 16; + final BigInteger expected = new BigInteger("-1ABCDEF", radix); + + // offset=0, full array. + char[] exact = "-1ABCDEF".toCharArray(); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(exact, 0, exact.length, radix, true)); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(exact, 0, exact.length, radix, false)); + + // offset=0, length truncates trailing chars. + char[] trailing = "-1ABCDEFzz".toCharArray(); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(trailing, 0, 8, radix, true)); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(trailing, 0, 8, radix, false)); + + // offset>0, slice in the middle of a larger buffer. + char[] padded = ("xx" + "-1ABCDEF" + "yy").toCharArray(); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(padded, 2, 8, radix, true)); + assertEquals(expected, NumberInput.parseBigIntegerWithRadix(padded, 2, 8, radix, false)); + } + + @Test + void bigIntegerWithRadixFromCharArrayInvalid() + { + // Non-hex characters -> NFE on both paths. + char[] bad = "GHIJ".toCharArray(); + assertThrows(NumberFormatException.class, + () -> NumberInput.parseBigIntegerWithRadix(bad, 0, bad.length, 16, true)); + assertThrows(NumberFormatException.class, + () -> NumberInput.parseBigIntegerWithRadix(bad, 0, bad.length, 16, false)); + } + @Test void parseBigIntegerFailsWithENotation() { diff --git a/src/test/java/tools/jackson/core/unittest/json/async/AsyncHexNumbers707Test.java b/src/test/java/tools/jackson/core/unittest/json/async/AsyncHexNumbers707Test.java new file mode 100644 index 0000000000..bfae85dcdd --- /dev/null +++ b/src/test/java/tools/jackson/core/unittest/json/async/AsyncHexNumbers707Test.java @@ -0,0 +1,183 @@ +package tools.jackson.core.unittest.json.async; + +import java.math.BigInteger; + +import org.junit.jupiter.api.Test; + +import tools.jackson.core.JsonToken; +import tools.jackson.core.exc.StreamReadException; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonReadFeature; +import tools.jackson.core.unittest.async.AsyncTestBase; +import tools.jackson.core.unittest.testutil.AsyncReaderWrapper; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for [core#707]: JSON5-style hexadecimal integer literals via the + * non-blocking (async) UTF-8 parser. Exercises the {@code 1}-byte and + * {@code 3}-byte feed sizes to drive suspension at every position. + */ +class AsyncHexNumbers707Test extends AsyncTestBase +{ + private final JsonFactory HEX_F = JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS) + .build(); + + private final JsonFactory HEX_AND_PLUS_F = JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS) + .enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS) + .build(); + + @Test + void unsignedHexFullBuffer() throws Exception { + _expectInt(HEX_F, "0xc0ffee", "0xc0ffee", 0xC0FFEE, 1000); + } + + @Test + void unsignedHexOneBytePerRead() throws Exception { + // Forces suspension after every single byte + _expectInt(HEX_F, "0xc0ffee", "0xc0ffee", 0xC0FFEE, 1); + } + + @Test + void uppercaseHexOneBytePerRead() throws Exception { + _expectInt(HEX_F, "0XCAFE", "0XCAFE", 0xCAFE, 1); + } + + @Test + void negativeHexOneBytePerRead() throws Exception { + _expectInt(HEX_F, "-0x10", "-0x10", -16, 1); + } + + @Test + void positiveHexWithPlusSignAndSplit() throws Exception { + _expectInt(HEX_AND_PLUS_F, "+0xff", "+0xff", 0xFF, 1); + } + + @Test + void plainZeroStillWorks() throws Exception { + // Make sure we didn't break the bare "0" path + try (AsyncReaderWrapper r = asyncForBytes(HEX_F, 1, _jsonDoc(" 0 "), 1)) { + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(0, r.getIntValue()); + } + } + + @Test + void hexInsideArray() throws Exception { + for (int readSize : new int[] {1, 3, 1000}) { + try (AsyncReaderWrapper r = asyncForBytes(HEX_F, readSize, + _jsonDoc("[0x1, 0xFF, -0x10]"), 1)) { + assertToken(JsonToken.START_ARRAY, r.nextToken()); + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(1, r.getIntValue()); + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(255, r.getIntValue()); + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(-16, r.getIntValue()); + assertToken(JsonToken.END_ARRAY, r.nextToken()); + } + } + } + + @Test + void hexBigIntegerRange() throws Exception { + final String literal = "0x1ffffffffffffffff"; + final BigInteger expected = new BigInteger("1ffffffffffffffff", 16); + for (int readSize : new int[] {1, 5, 1000}) { + try (AsyncReaderWrapper r = asyncForBytes(HEX_F, readSize, + _jsonDoc(" " + literal + " "), 1)) { + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(literal, r.currentText()); + assertEquals(expected, r.getBigIntegerValue()); + } + } + } + + @Test + void hexNegativeBigIntegerRange() throws Exception { + // 17 hex digits with sign -> must promote to (negative) BigInteger via async resumption + final String literal = "-0x1ffffffffffffffff"; + final BigInteger expected = new BigInteger("-1ffffffffffffffff", 16); + for (int readSize : new int[] {1, 5, 1000}) { + try (AsyncReaderWrapper r = asyncForBytes(HEX_F, readSize, + _jsonDoc(" " + literal + " "), 1)) { + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(literal, r.currentText()); + assertEquals(expected, r.getBigIntegerValue()); + } + } + } + + @Test + void hex16DigitsLongMax() throws Exception { + // 16 digits, top nibble == 7 -> stays on the long fast path (Long.MAX_VALUE). + // Boundary case in _parseHexInt: hexLen == 16 && topNibble < 0x8. + final String literal = "0x7fffffffffffffff"; + for (int readSize : new int[] {1, 5, 1000}) { + try (AsyncReaderWrapper r = asyncForBytes(HEX_F, readSize, + _jsonDoc(" " + literal + " "), 1)) { + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(literal, r.currentText()); + assertEquals(Long.MAX_VALUE, r.getLongValue()); + } + } + } + + @Test + void hex16DigitsOverflowsToBigInteger() throws Exception { + // 16 digits, top nibble == 8 -> falls off the long fast path into the + // BigInteger arm of _parseHexInt (value is 2^63, just past Long.MAX_VALUE). + final String literal = "0x8000000000000000"; + final BigInteger expected = BigInteger.ONE.shiftLeft(63); + for (int readSize : new int[] {1, 5, 1000}) { + try (AsyncReaderWrapper r = asyncForBytes(HEX_F, readSize, + _jsonDoc(" " + literal + " "), 1)) { + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(literal, r.currentText()); + assertEquals(expected, r.getBigIntegerValue()); + } + } + } + + @Test + void hexRejectedWhenFeatureDisabled() throws Exception { + JsonFactory plain = new JsonFactory(); + try (AsyncReaderWrapper r = asyncForBytes(plain, 1, _jsonDoc(" 0xff "), 1)) { + r.nextToken(); + fail("Should not pass when ALLOW_HEXADECIMAL_NUMBERS is disabled"); + } catch (StreamReadException e) { + // Error now names the feature that must be enabled (see _checkHexNumbersAllowed). + verifyException(e, "Unexpected character ('x'"); + verifyException(e, "ALLOW_HEXADECIMAL_NUMBERS"); + } + } + + @Test + void hexPrefixWithoutDigitsFails() throws Exception { + try (AsyncReaderWrapper r = asyncForBytes(HEX_F, 1, _jsonDoc(" 0x "), 1)) { + r.nextToken(); + fail("Should not pass: prefix without any hex digit"); + } catch (StreamReadException e) { + verifyException(e, "hex digit"); + } + } + + private void _expectInt(JsonFactory factory, String literal, String expectedText, + long expectedValue, int readSize) throws Exception + { + String input = " " + literal + " "; + try (AsyncReaderWrapper r = asyncForBytes(factory, readSize, _jsonDoc(input), 1)) { + assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken()); + assertEquals(expectedText, r.currentText(), + "currentText() literal mismatch for " + literal + " (readSize " + readSize + ")"); + assertEquals(expectedValue, r.getLongValue(), + "long value mismatch for " + literal + " (readSize " + readSize + ")"); + if (expectedValue >= Integer.MIN_VALUE && expectedValue <= Integer.MAX_VALUE) { + assertEquals((int) expectedValue, r.getIntValue(), + "int value mismatch for " + literal + " (readSize " + readSize + ")"); + } + } + } +} diff --git a/src/test/java/tools/jackson/core/unittest/read/NonStandardHexNumbers707Test.java b/src/test/java/tools/jackson/core/unittest/read/NonStandardHexNumbers707Test.java new file mode 100644 index 0000000000..885881b4ec --- /dev/null +++ b/src/test/java/tools/jackson/core/unittest/read/NonStandardHexNumbers707Test.java @@ -0,0 +1,190 @@ +package tools.jackson.core.unittest.read; + +import java.math.BigInteger; + +import org.junit.jupiter.api.Test; + +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.exc.StreamConstraintsException; +import tools.jackson.core.exc.StreamReadException; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonReadFeature; +import tools.jackson.core.unittest.JacksonCoreTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for [core#707]: JSON5-style hexadecimal integer literals enabled via + * {@link JsonReadFeature#ALLOW_HEXADECIMAL_NUMBERS}. + */ +class NonStandardHexNumbers707Test extends JacksonCoreTestBase +{ + private final JsonFactory HEX_F = JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS) + .build(); + + private final JsonFactory HEX_AND_PLUS_F = JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS) + .enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS) + .build(); + + @Test + void unsignedHexLowercase() throws Exception { + _expectInt(HEX_F, "0xc0ffee", "0xc0ffee", 0xC0FFEE); + } + + @Test + void unsignedHexUppercaseXAndDigits() throws Exception { + _expectInt(HEX_F, "0XC0FFEE", "0XC0FFEE", 0xC0FFEE); + } + + @Test + void unsignedHexZero() throws Exception { + _expectInt(HEX_F, "0x0", "0x0", 0); + } + + @Test + void unsignedHexWithLeadingZeros() throws Exception { + // [core#707]: leading zeros are always permitted in hex digits, regardless + // of ALLOW_LEADING_ZEROS_FOR_NUMBERS. + _expectInt(HEX_F, "0x007F", "0x007F", 0x7F); + } + + @Test + void negativeHex() throws Exception { + _expectInt(HEX_F, "-0x10", "-0x10", -16); + } + + @Test + void plusSignHexRequiresLeadingPlusFeature() throws Exception { + // Without ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS, +0xff must still fail + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(HEX_F, mode, " +0xff ")) { + p.nextToken(); + fail("Should not pass when ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS is disabled"); + } catch (StreamReadException e) { + verifyException(e, "plus sign"); + } + } + } + + @Test + void plusSignHexWithPlusFeature() throws Exception { + _expectInt(HEX_AND_PLUS_F, "+0xff", "+0xff", 0xFF); + } + + @Test + void hexLongRange() throws Exception { + // Just over Integer.MAX_VALUE (0x7fffffff = 2147483647) -> 0x80000000 = 2147483648L + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(HEX_F, mode, " 0x80000000 ")) { + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals("0x80000000", p.getString()); + assertEquals(2147483648L, p.getLongValue()); + } + } + } + + @Test + void hexBigIntegerRange() throws Exception { + // 17 hex digits -> must promote to BigInteger + final String literal = "0x1ffffffffffffffff"; + final BigInteger expected = new BigInteger("1ffffffffffffffff", 16); + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(HEX_F, mode, " " + literal + " ")) { + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(literal, p.getString()); + assertEquals(expected, p.getBigIntegerValue()); + } + } + } + + @Test + void hexInsideArray() throws Exception { + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(HEX_F, mode, "[0x1, 0x2, -0xA]")) { + assertToken(JsonToken.START_ARRAY, p.nextToken()); + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(1, p.getIntValue()); + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(2, p.getIntValue()); + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(-10, p.getIntValue()); + assertToken(JsonToken.END_ARRAY, p.nextToken()); + } + } + } + + @Test + void hexRejectedWhenFeatureDisabled() throws Exception { + // With the feature OFF, 0x... must still fail like in vanilla JSON, + // and the error message must point at the feature to enable. + JsonFactory plainF = new JsonFactory(); + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(plainF, mode, " 0xc0ffee ")) { + p.nextToken(); + fail("Should not pass when ALLOW_HEXADECIMAL_NUMBERS is disabled"); + } catch (StreamReadException e) { + verifyException(e, "Unexpected character ('x'"); + verifyException(e, "ALLOW_HEXADECIMAL_NUMBERS"); + } + } + } + + @Test + void hexPrefixWithoutDigitsFails() throws Exception { + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(HEX_F, mode, " 0x ")) { + p.nextToken(); + fail("Should not pass: prefix without any hex digit"); + } catch (StreamReadException e) { + verifyException(e, "hex digit"); + } + } + } + + @Test + void hexNumberLengthConstraint() throws Exception { + // Build a hex literal long enough to cross several TextBuffer segment + // boundaries (default first segment is 500 chars; using 8000 digits to + // ensure we definitely span at least one boundary on every backend). + StringBuilder sb = new StringBuilder("0x"); + for (int i = 0; i < 8000; i++) { + sb.append('f'); + } + final String hugeHex = sb.toString(); + JsonFactory cappedF = JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_HEXADECIMAL_NUMBERS) + .streamReadConstraints(StreamReadConstraints.builder().maxNumberLength(100).build()) + .build(); + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(cappedF, mode, " " + hugeHex + " ")) { + p.nextToken(); + fail("Should not pass: hex literal exceeds maxNumberLength (mode " + mode + ")"); + } catch (StreamConstraintsException e) { + verifyException(e, "exceeds the maximum"); + } + } + } + + private void _expectInt(JsonFactory factory, String literal, String expectedText, long expectedValue) + throws Exception + { + String input = " " + literal + " "; + for (int mode : ALL_MODES) { + try (JsonParser p = createParser(factory, mode, input)) { + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(expectedText, p.getString(), + "getString() literal mismatch for " + literal + " (mode " + mode + ")"); + assertEquals(expectedValue, p.getLongValue(), + "long value mismatch for " + literal + " (mode " + mode + ")"); + if (expectedValue >= Integer.MIN_VALUE && expectedValue <= Integer.MAX_VALUE) { + assertEquals((int) expectedValue, p.getIntValue(), + "int value mismatch for " + literal + " (mode " + mode + ")"); + } + } + } + } +}