diff --git a/Sources/SwiftProtobuf/JSONReader.swift b/Sources/SwiftProtobuf/JSONReader.swift index 916b300c8..a96207e10 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,79 @@ 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 + /// 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 { + 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 + ) } - // 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") - } - 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) - } + 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 } + 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 } - /// 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") - } + /// 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)]) + } - // 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 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 - 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 + return nil } + var subTokenizer = Tokenizer(buffer: utf8Bytes, mode: .json, errorCode: .jsonDecodingError) + try subTokenizer.next() + return subTokenizer } /// Consumes a double-quoted string, resolving any escapes. @@ -351,17 +299,14 @@ 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() - switch stringValue { - case "NaN": - return .nan - case "Inf", "Infinity": - return .infinity - case "-Inf", "-Infinity": - return -.infinity - default: - return try parseDouble(from: stringValue[...]) + if at(.string) { + let utf8Bytes = try consumeUnescapedStringBytes() + return try parseQuotedDouble(from: utf8Bytes) + } + if at(.stringWithEscapes) { + var stringValue = try consumeString() + return try stringValue.withUTF8 { utf8Bytes in + try parseQuotedDouble(from: utf8Bytes) } } @@ -381,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 { diff --git a/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift new file mode 100644 index 000000000..0fd8f59a2 --- /dev/null +++ b/Tests/SwiftProtobufTests/Test_DoubleParser_Locale.swift @@ -0,0 +1,92 @@ +// 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_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\"")) + } +}