From 0bfdd324024cdbca7c6553571b943c772606b88f Mon Sep 17 00:00:00 2001 From: Kaif Date: Thu, 15 Oct 2026 19:51:40 +0530 Subject: [PATCH 1/4] make json and text format number parsing locale-independent (#2187) In a process whose `LC_NUMERIC` locale uses a comma decimal separator, every fractional float or double value fails to decode: ```swift setlocale(LC_NUMERIC, "fr_FR") try Google_Protobuf_DoubleValue(jsonString: "1.5") // throws invalidUTF8 try Google_Protobuf_DoubleValue(textFormatString: "value: 3.75") // throws malformedNumber ``` `DoubleParser.utf8ToDouble` parses the pre-validated token with `strtod()`, which obeys the current C numeric locale: under a comma-decimal locale it stops at the `.`, the consumed-length check fails, and the value is rejected. Any code in the process calling `setlocale(LC_ALL, "")` (common in GUI apps and localized CLI code) breaks JSON and TextFormat decoding this way. The C++ implementation parses numbers locale-independently in both codecs: `absl::SimpleAtod` in json/internal/lexer.cc:266 and `NoLocaleStrtod` in io/tokenizer.cc:1034, backed by `absl::from_chars` in io/strtod.cc. The fix keeps the `strtod()` fast path and, only when it does not consume the whole token, retries with Swift's `Double(_:)`, which the stdlib pins to the C locale. In the C locale this changes nothing: `Double(_:)` accepts exactly the fully consumed `strtod()` forms, so anything rejected before is still rejected (checked against `1.2.3`, `1e`, `1e+`, `1.5x`, and out-of-range `1e309`). The new test switches `LC_NUMERIC` to a comma-decimal locale when one is installed and skips otherwise, since minimal CI images often ship only the C locale. It covers float and double in both codecs and that malformed numbers still fail. This fix was found and prepared with AI assistance and reviewed by me before submitting. --- .../Test_DoubleParser_Locale.swift | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift diff --git a/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift new file mode 100644 index 000000000..bbe461a74 --- /dev/null +++ b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift @@ -0,0 +1,80 @@ +// Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift - Locale-independent number parsing +// +// Copyright (c) 2014 - 2026 Apple Inc. and the project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See LICENSE.txt for license information: +// https://github.com/apple/swift-protobuf/blob/main/LICENSE.txt +// +// ----------------------------------------------------------------------------- +/// +/// JSON and TextFormat number parsing must not depend on the process' +/// C numeric locale: strtod() stops at "." under a comma-decimal locale. +/// +// ----------------------------------------------------------------------------- + +import Foundation +import SwiftProtobuf +import XCTest + +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#elseif canImport(WASILibc) +import WASILibc +#elseif canImport(Darwin) +import Darwin +#elseif os(Windows) +import CRT +#endif + +final class Test_DoubleParser_Locale: XCTestCase { + + /// True if strtod() currently fails to consume all of "1.5", which is + /// what happens under a locale whose decimal separator is not ".". + private func strtodStopsAtDot() -> Bool { + "1.5".withCString { buf in + var end: UnsafeMutablePointer? = nil + let value = strtod(buf, &end) + return value != 1.5 || end != UnsafeMutablePointer(mutating: buf) + 3 + } + } + + func testFractionalNumbersUnderCommaDecimalLocale() throws { + let saved = setlocale(LC_NUMERIC, nil).map { String(cString: $0) } + defer { + if let saved = saved { + setlocale(LC_NUMERIC, saved) + } + } + + // Switch LC_NUMERIC to a locale whose decimal separator is ",". + // Locale availability varies by platform and image, so try a few + // spellings and skip if none of them affects strtod(). + let candidates = ["fr_FR.UTF-8", "fr_FR", "de_DE.UTF-8", "de_DE", "fr-FR", "de-DE"] + var localeInUse: String? = nil + for candidate in candidates { + if setlocale(LC_NUMERIC, candidate) != nil, strtodStopsAtDot() { + localeInUse = candidate + break + } + } + guard localeInUse != nil else { + throw XCTSkip("No comma-decimal locale available to exercise strtod()") + } + + // Fractional and exponent forms must decode the same as in the C locale. + XCTAssertEqual(try Google_Protobuf_DoubleValue(jsonString: "1.5").value, 1.5) + XCTAssertEqual(try Google_Protobuf_FloatValue(jsonString: "2.25").value, 2.25) + XCTAssertEqual(try Google_Protobuf_DoubleValue(jsonString: "6.25e-2").value, 0.0625) + XCTAssertEqual(try Google_Protobuf_DoubleValue(textFormatString: "value: 3.75").value, 3.75) + XCTAssertEqual(try Google_Protobuf_FloatValue(textFormatString: "value: 1.5f").value, 1.5) + + // Malformed numbers must still be rejected. + XCTAssertThrowsError(try Google_Protobuf_DoubleValue(textFormatString: "value: 1.2.3")) + XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "1.5x")) + } +} From 864e7cfa05f2e3ed20451b198ed0733293a9eaba Mon Sep 17 00:00:00 2001 From: Thomas Van Lenten Date: Thu, 15 Oct 2026 11:13:11 -0400 Subject: [PATCH 2/4] Document the strtod usage constraints. (#2191) Followup to 87cc8b1143839dbe47eac2b727d238eec7b0b637. Also make the new test explicit that things like "1,2" won't get accepted even though that covered in some other places too. --- Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift index bbe461a74..5522f0696 100644 --- a/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift +++ b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift @@ -76,5 +76,8 @@ final class Test_DoubleParser_Locale: XCTestCase { // Malformed numbers must still be rejected. XCTAssertThrowsError(try Google_Protobuf_DoubleValue(textFormatString: "value: 1.2.3")) XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "1.5x")) + // Configure the locale impacts of `strtod` don't let invalid input through. + XCTAssertThrowsError(try Google_Protobuf_DoubleValue(textFormatString: "1,5")) + XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "1,5")) } } From 353f50cc1f31ad4dd83327a8cb1dca29571f568f Mon Sep 17 00:00:00 2001 From: Thomas Van Lenten Date: Thu, 15 Oct 2026 12:02:12 -0400 Subject: [PATCH 3/4] [table-driven-branch] Reuse JSONReader/Tokenizer for quoted JSON numbers Instead of maintaining duplicate string-number parsing and calling strtod in JSONReader, parse quoted JSON numbers using a sub-JSONReader over the decoded UTF-8 bytes so that quoted and unquoted numbers share the exact same tokenizer validation and locale-independent conversion. --- Sources/SwiftProtobuf/JSONReader.swift | 196 ++++++------------ .../Test_DoubleParser_Locale.swift | 9 + 2 files changed, 68 insertions(+), 137 deletions(-) diff --git a/Sources/SwiftProtobuf/JSONReader.swift b/Sources/SwiftProtobuf/JSONReader.swift index 916b300c8..9282c7ef1 100644 --- a/Sources/SwiftProtobuf/JSONReader.swift +++ b/Sources/SwiftProtobuf/JSONReader.swift @@ -12,20 +12,6 @@ /// // ----------------------------------------------------------------------------- -#if canImport(Glibc) -import Glibc -#elseif canImport(Musl) -import Musl -#elseif canImport(Android) -import Android -#elseif canImport(WASILibc) -import WASILibc -#elseif canImport(Darwin) -import Darwin -#elseif os(Windows) -import CRT -#endif - #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -150,28 +136,12 @@ struct JSONReader: TextualParser { precondition(upperBound >= 0, "Upper bound must be non-negative") var negative = false - var upperBound = UInt64(upperBound) let unsigned: UInt64 if at(.string, .stringWithEscapes) { - var stringValue = try consumeString()[...] - - if stringValue.first == "-" { - stringValue = stringValue.dropFirst() - negative = true - // Two's compliment always allows one more negative integer than positive. - upperBound += 1 - } - - unsigned = try parseUnsignedInteger(from: stringValue, upperBound: UInt64(upperBound)) + unsigned = try parseQuotedInteger(isSigned: true, negative: &negative, upperBound: UInt64(upperBound)) } else { - if try consumeIfPresent(.minus) { - negative = true - // Two's compliment always allows one more negative integer than positive. - upperBound += 1 - } - - unsigned = try consumeUnsignedInteger(upperBound: upperBound) + unsigned = try consumeSignedMagnitude(negative: &negative, upperBound: UInt64(upperBound)) } if negative { @@ -184,15 +154,27 @@ struct JSONReader: TextualParser { } } + private mutating func consumeSignedMagnitude( + negative: inout Bool, + upperBound: UInt64 + ) throws -> UInt64 { + var upperBound = upperBound + if try consumeIfPresent(.minus) { + negative = true + // Two's compliment always allows one more negative integer than positive. + upperBound += 1 + } + return try consumeUnsignedInteger(upperBound: upperBound) + } + /// Consumes an unsigned integer from the tokenizer. /// /// - Parameter upperBound: The maximum value of the integer to consume. If the integer is /// greater than `upperBound`, an error will be thrown. mutating func consumeUnsignedInteger(upperBound: UInt64) throws -> UInt64 { if at(.string, .stringWithEscapes) { - // Strings are allowed. - let string = try consumeString()[...] - return try parseUnsignedInteger(from: string, upperBound: upperBound) + var negative = false + return try parseQuotedInteger(isSigned: false, negative: &negative, upperBound: upperBound) } if at(.float) { // Floating-point numbers are allowed as long as they correspond exactly to an integer @@ -214,113 +196,44 @@ struct JSONReader: TextualParser { return value } - /// Parses a string into an unsigned integer. - /// - /// - Parameters: - /// - string: The string to parse. - /// - upperBound: The maximum value of the integer to consume. If the integer is - /// greater than `upperBound`, an error will be thrown. - private mutating func parseUnsignedInteger(from string: Substring, upperBound: UInt64) throws -> UInt64 { - func valueError() -> SwiftProtobufError { - parsingError(reason: "Expected an integer in 0...\(upperBound)") - } - - if string.first == "0" && string.count > 1 { - throw parsingError(reason: "Leading zeros are not allowed in integers") - } - - if let value = UInt64(string) { - guard value <= upperBound else { - throw valueError() - } - return value - } - // Try to parse it as a Double; e.g., an exact integer in exponential form. - let doubleValue = try parseDouble(from: string) - if let uint64Value = UInt64(exactly: doubleValue), uint64Value <= upperBound { - return uint64Value - } - throw valueError() - } - - /// Parses a string into a double. - /// - /// - Parameter string: The string to parse. - private mutating func parseDouble(from string: Substring) throws -> Double { - // The tokenizer handles this for regular numbers, but for numbers represented as strings, - // we need to make sure there's at least one digit before and after the decimal point. - let stringWithoutMinus = (string.utf8.first == UInt8(ascii: "-")) ? string.dropFirst() : string[...] - if let dotIndex = stringWithoutMinus.firstIndex(of: ".") { - let beforeDot = stringWithoutMinus.prefix(upTo: dotIndex) - guard !beforeDot.isEmpty else { - throw parsingError(reason: "Numbers cannot start with a decimal point in JSON") + /// Consumes a double-quoted string, resolving any escapes, and parses it as an integer using + /// `Tokenizer` so that quoted and unquoted numbers share the exact same validation. + private mutating func parseQuotedInteger( + isSigned: Bool, + negative: inout Bool, + upperBound: UInt64 + ) throws -> UInt64 { + var stringValue = try consumeString() + let originalTokenizer = self.tokenizer + defer { self.tokenizer = originalTokenizer } + return try stringValue.withUTF8 { utf8Bytes in + self.tokenizer = try makeQuotedNumberTokenizer(utf8Bytes) + let result = + isSigned + ? try consumeSignedMagnitude(negative: &negative, upperBound: upperBound) + : try consumeUnsignedInteger(upperBound: upperBound) + guard complete else { + throw parsingError(reason: "Invalid number string") } - guard beforeDot.count == 1 || beforeDot.utf8.first != UInt8(ascii: "0") else { - throw parsingError(reason: "Leading zeros are not allowed in JSON") - } - guard !stringWithoutMinus.suffix(from: stringWithoutMinus.index(after: dotIndex)).isEmpty else { - throw parsingError(reason: "Decimal point must be followed by digits in JSON") - } - } else { - guard stringWithoutMinus.count == 1 || stringWithoutMinus.utf8.first != UInt8(ascii: "0") else { - throw parsingError(reason: "Leading zeros are not allowed in JSON") - } - } - - switch string { - case "NaN": - return .nan - case "Inf", "Infinity": - return .infinity - case "-Inf", "-Infinity": - return -.infinity - default: - return try parseDoubleRaw(string) + return result } } - /// Parses a floating-point number from a string using the system's `strtod` function. - /// - /// - Parameter string: The string to parse. - /// - Throws: `SwiftProtobufError` if the string does not represent a valid floating-point number. - private func parseDoubleRaw(_ string: Substring) throws -> Double { - func valueError() -> SwiftProtobufError { - parsingError(reason: "Invalid floating point value") - } - - // Reject unreasonably small or large numbers, or numbers that don't start with a digit or - // minus sign (strtod isn't very strict and allows leading whitespace). - let capacity = 128 + /// Creates a `Tokenizer` over the UTF-8 bytes of a quoted number string, validating that it + /// starts with a minus sign (immediately followed by a digit) or digit and ends with a digit + /// (so leading, internal, and trailing whitespace are rejected). + private func makeQuotedNumberTokenizer(_ utf8Bytes: UnsafeBufferPointer) throws -> Tokenizer { + let firstDigitIndex = (utf8Bytes.first == UInt8(ascii: "-")) ? 1 : 0 guard - let first = string.utf8.first, - string.utf8.count < capacity - 1, - first == UInt8(ascii: "-") || Tokenizer.isDigit(first) + firstDigitIndex < utf8Bytes.count, + Tokenizer.isDigit(utf8Bytes[firstDigitIndex]), + Tokenizer.isDigit(utf8Bytes.last!) else { - throw valueError() - } - return try withUnsafeTemporaryAllocation(of: CChar.self, capacity: capacity) { buffer in - var string = string - string.withUTF8 { utf8Bytes in - utf8Bytes.withMemoryRebound(to: CChar.self) { cChars in - let (_, endIndex) = buffer.initialize(from: cChars) - buffer[endIndex] = 0 - } - } - - let ccharPointer = buffer.baseAddress! - var lastConsumed: UnsafeMutablePointer? = nil - let doubleValue = strtod(ccharPointer, &lastConsumed) - - // Fail if `strtod` did not consume everything we expected or if the value was not - // finite (which would happen if it was too large or too small). - guard - let lastConsumed = lastConsumed, - lastConsumed == ccharPointer + string.utf8.count, doubleValue.isFinite - else { - throw valueError() - } - return doubleValue + throw parsingError(reason: "Invalid number string") } + var subTokenizer = Tokenizer(buffer: utf8Bytes, mode: .json, errorCode: .jsonDecodingError) + try subTokenizer.next() + return subTokenizer } /// Consumes a double-quoted string, resolving any escapes. @@ -352,7 +265,7 @@ struct JSONReader: TextualParser { /// Consumes a 64-bit floating point value from the tokenizer. mutating func consumeDouble() throws -> Double { if at(.string, .stringWithEscapes) { - let stringValue = try consumeString() + var stringValue = try consumeString() switch stringValue { case "NaN": return .nan @@ -361,7 +274,16 @@ struct JSONReader: TextualParser { case "-Inf", "-Infinity": return -.infinity default: - return try parseDouble(from: stringValue[...]) + let originalTokenizer = self.tokenizer + defer { self.tokenizer = originalTokenizer } + return try stringValue.withUTF8 { utf8Bytes in + self.tokenizer = try makeQuotedNumberTokenizer(utf8Bytes) + let result = try consumeDouble() + guard complete else { + throw parsingError(reason: "Invalid number string") + } + return result + } } } diff --git a/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift index 5522f0696..0fd8f59a2 100644 --- a/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift +++ b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift @@ -68,16 +68,25 @@ final class Test_DoubleParser_Locale: XCTestCase { // Fractional and exponent forms must decode the same as in the C locale. XCTAssertEqual(try Google_Protobuf_DoubleValue(jsonString: "1.5").value, 1.5) + XCTAssertEqual(try Google_Protobuf_DoubleValue(jsonString: "\"1.5\"").value, 1.5) XCTAssertEqual(try Google_Protobuf_FloatValue(jsonString: "2.25").value, 2.25) + XCTAssertEqual(try Google_Protobuf_FloatValue(jsonString: "\"2.25\"").value, 2.25) XCTAssertEqual(try Google_Protobuf_DoubleValue(jsonString: "6.25e-2").value, 0.0625) + XCTAssertEqual(try Google_Protobuf_DoubleValue(jsonString: "\"6.25e-2\"").value, 0.0625) + XCTAssertEqual(try Google_Protobuf_UInt64Value(jsonString: "\"1.5e2\"").value, 150) XCTAssertEqual(try Google_Protobuf_DoubleValue(textFormatString: "value: 3.75").value, 3.75) XCTAssertEqual(try Google_Protobuf_FloatValue(textFormatString: "value: 1.5f").value, 1.5) // Malformed numbers must still be rejected. XCTAssertThrowsError(try Google_Protobuf_DoubleValue(textFormatString: "value: 1.2.3")) XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "1.5x")) + XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "\"1.5x\"")) + XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "\"- 1.5\"")) + XCTAssertThrowsError(try Google_Protobuf_Int64Value(jsonString: "\"- 1\"")) // Configure the locale impacts of `strtod` don't let invalid input through. XCTAssertThrowsError(try Google_Protobuf_DoubleValue(textFormatString: "1,5")) XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "1,5")) + XCTAssertThrowsError(try Google_Protobuf_DoubleValue(jsonString: "\"1,5\"")) + XCTAssertThrowsError(try Google_Protobuf_UInt64Value(jsonString: "\"1,5e2\"")) } } From ebe5e9d670eaf65daf8336329adb5a57a9bbbcaf Mon Sep 17 00:00:00 2001 From: Thomas Van Lenten Date: Thu, 15 Oct 2026 14:24:39 -0400 Subject: [PATCH 4/4] [table-driven-branch] Avoid String creation when parsing unescaped quoted JSON numbers When a quoted JSON number has no backslash escapes (Token.Kind.string), slice tokenizer.current.text directly instead of decoding a String via consumeString(). Also defer the NaN/Infinity string comparison in parseQuotedDouble until after the leading/trailing digit check (and fail fast for input > 9 bytes) so numeric strings skip it entirely. --- Sources/SwiftProtobuf/JSONReader.swift | 118 ++++++++++++++++++------- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/Sources/SwiftProtobuf/JSONReader.swift b/Sources/SwiftProtobuf/JSONReader.swift index 9282c7ef1..a96207e10 100644 --- a/Sources/SwiftProtobuf/JSONReader.swift +++ b/Sources/SwiftProtobuf/JSONReader.swift @@ -203,33 +203,68 @@ struct JSONReader: TextualParser { negative: inout Bool, upperBound: UInt64 ) throws -> UInt64 { + if at(.string) { + let utf8Bytes = try consumeUnescapedStringBytes() + return try parseQuotedInteger( + from: utf8Bytes, + isSigned: isSigned, + negative: &negative, + upperBound: upperBound + ) + } var stringValue = try consumeString() + return try stringValue.withUTF8 { utf8Bytes in + try parseQuotedInteger( + from: utf8Bytes, + isSigned: isSigned, + negative: &negative, + upperBound: upperBound + ) + } + } + + private mutating func parseQuotedInteger( + from utf8Bytes: UnsafeBufferPointer, + isSigned: Bool, + negative: inout Bool, + upperBound: UInt64 + ) throws -> UInt64 { + guard let subTokenizer = try makeQuotedNumberTokenizer(utf8Bytes) else { + throw parsingError(reason: "Invalid number string") + } let originalTokenizer = self.tokenizer defer { self.tokenizer = originalTokenizer } - return try stringValue.withUTF8 { utf8Bytes in - self.tokenizer = try makeQuotedNumberTokenizer(utf8Bytes) - let result = - isSigned - ? try consumeSignedMagnitude(negative: &negative, upperBound: upperBound) - : try consumeUnsignedInteger(upperBound: upperBound) - guard complete else { - throw parsingError(reason: "Invalid number string") - } - return result + self.tokenizer = subTokenizer + let result = + isSigned + ? try consumeSignedMagnitude(negative: &negative, upperBound: upperBound) + : try consumeUnsignedInteger(upperBound: upperBound) + guard complete else { + throw parsingError(reason: "Invalid number string") } + return result + } + + /// Consumes a `.string` token (which has no escapes) and returns the slice of UTF-8 bytes + /// between the quotes without creating a `String`. + private mutating func consumeUnescapedStringBytes() throws -> UnsafeBufferPointer { + let text = tokenizer.current.text + _ = try tokenizer.next() + return UnsafeBufferPointer(rebasing: text[1..<(text.count - 1)]) } - /// Creates a `Tokenizer` over the UTF-8 bytes of a quoted number string, validating that it - /// starts with a minus sign (immediately followed by a digit) or digit and ends with a digit - /// (so leading, internal, and trailing whitespace are rejected). - private func makeQuotedNumberTokenizer(_ utf8Bytes: UnsafeBufferPointer) throws -> Tokenizer { + /// Creates a `Tokenizer` over the UTF-8 bytes of a quoted number string if it starts with a + /// minus sign (immediately followed by a digit) or digit and ends with a digit (so leading, + /// internal, and trailing whitespace are rejected). Returns `nil` if the bytes do not start + /// and end with digits. + private func makeQuotedNumberTokenizer(_ utf8Bytes: UnsafeBufferPointer) throws -> Tokenizer? { let firstDigitIndex = (utf8Bytes.first == UInt8(ascii: "-")) ? 1 : 0 guard firstDigitIndex < utf8Bytes.count, Tokenizer.isDigit(utf8Bytes[firstDigitIndex]), Tokenizer.isDigit(utf8Bytes.last!) else { - throw parsingError(reason: "Invalid number string") + return nil } var subTokenizer = Tokenizer(buffer: utf8Bytes, mode: .json, errorCode: .jsonDecodingError) try subTokenizer.next() @@ -264,26 +299,14 @@ struct JSONReader: TextualParser { /// Consumes a 64-bit floating point value from the tokenizer. mutating func consumeDouble() throws -> Double { - if at(.string, .stringWithEscapes) { + if at(.string) { + let utf8Bytes = try consumeUnescapedStringBytes() + return try parseQuotedDouble(from: utf8Bytes) + } + if at(.stringWithEscapes) { var stringValue = try consumeString() - switch stringValue { - case "NaN": - return .nan - case "Inf", "Infinity": - return .infinity - case "-Inf", "-Infinity": - return -.infinity - default: - let originalTokenizer = self.tokenizer - defer { self.tokenizer = originalTokenizer } - return try stringValue.withUTF8 { utf8Bytes in - self.tokenizer = try makeQuotedNumberTokenizer(utf8Bytes) - let result = try consumeDouble() - guard complete else { - throw parsingError(reason: "Invalid number string") - } - return result - } + return try stringValue.withUTF8 { utf8Bytes in + try parseQuotedDouble(from: utf8Bytes) } } @@ -303,6 +326,33 @@ struct JSONReader: TextualParser { } } + private mutating func parseQuotedDouble(from utf8Bytes: UnsafeBufferPointer) throws -> Double { + guard let subTokenizer = try makeQuotedNumberTokenizer(utf8Bytes) else { + // The longest valid value here is "-Infinity" (9 bytes), so fail fast on larger input. + guard utf8Bytes.count <= 9 else { + throw parsingError(reason: "Invalid number string") + } + switch String(decoding: utf8Bytes, as: UTF8.self) { + case "NaN": + return .nan + case "Inf", "Infinity": + return .infinity + case "-Inf", "-Infinity": + return -.infinity + default: + throw parsingError(reason: "Invalid number string") + } + } + let originalTokenizer = self.tokenizer + defer { self.tokenizer = originalTokenizer } + self.tokenizer = subTokenizer + let result = try consumeDouble() + guard complete else { + throw parsingError(reason: "Invalid number string") + } + return result + } + /// Consumes an unsigned decimal integer or floating point value from the tokenizer. mutating func consumeUnsignedDecimalAsDouble() throws -> Double { guard at(.integer) else {