diff --git a/Foundation/JSONEncoder.swift b/Foundation/JSONEncoder.swift index 9da3572bee..7767332d67 100644 --- a/Foundation/JSONEncoder.swift +++ b/Foundation/JSONEncoder.swift @@ -1909,7 +1909,14 @@ extension _JSONDecoder { // * and the integral value was <= Float.greatestFiniteMagnitude, we are willing to lose precision past 2^24 // * If it was a Float, you will get back the precise value // * If it was a Double or Decimal, you will get back the nearest approximation if it will fit - let double = number.doubleValue + + let double: Double + if value is NSDecimalNumber { + // Prefer to parse the NSDecimalNumber as a Double so that the value roundtrips correctly + double = Double(number.description) ?? number.doubleValue + } else { + double = number.doubleValue + } guard abs(double) <= Double(Float.greatestFiniteMagnitude) else { throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number \(number) does not fit in \(type).")) } @@ -1952,7 +1959,11 @@ extension _JSONDecoder { // We are always willing to return the number as a Double: // * If the original value was integral, it is guaranteed to fit in a Double; we are willing to lose precision past 2^53 if you encoded a UInt64 but requested a Double // * If it was a Float or Double, you will get back the precise value - // * If it was Decimal, you will get back the nearest approximation + // * If it was Decimal, try reparsing as a Double to get a round-tripable value + if value is NSDecimalNumber, let d = Double(number.description) { + return d + } + return number.doubleValue /* FIXME: If swift-corelibs-foundation doesn't change to use NSNumber, this code path will need to be included and tested: diff --git a/Foundation/JSONSerialization.swift b/Foundation/JSONSerialization.swift index a54c5c75f0..826149060e 100644 --- a/Foundation/JSONSerialization.swift +++ b/Foundation/JSONSerialization.swift @@ -822,45 +822,61 @@ private struct JSONReader { ] func parseNumber(_ input: Index, options opt: JSONSerialization.ReadingOptions) throws -> (Any, Index)? { - func parseTypedNumber(_ address: UnsafePointer, count: Int) -> (Any, IndexDistance)? { - let temp_buffer_size = 64 - var temp_buffer = [Int8](repeating: 0, count: temp_buffer_size) - return temp_buffer.withUnsafeMutableBufferPointer { (buffer: inout UnsafeMutableBufferPointer) -> (Any, IndexDistance)? in - memcpy(buffer.baseAddress!, address, min(count, temp_buffer_size - 1)) // ensure null termination - - let startPointer = buffer.baseAddress! - let intEndPointer = UnsafeMutablePointer?>.allocate(capacity: 1) - defer { intEndPointer.deallocate() } - let doubleEndPointer = UnsafeMutablePointer?>.allocate(capacity: 1) - defer { doubleEndPointer.deallocate() } - let intResult = strtol(startPointer, intEndPointer, 10) - let intDistance = startPointer.distance(to: intEndPointer[0]!) - let doubleResult = strtod(startPointer, doubleEndPointer) - let doubleDistance = startPointer.distance(to: doubleEndPointer[0]!) - - guard doubleDistance > 0 else { return nil } - if intDistance == doubleDistance { - return (NSNumber(value: intResult), intDistance) - } - return (NSNumber(value: doubleResult), doubleDistance) + let ZERO = UInt8(ascii: "0") + let ONE = UInt8(ascii: "1") + let NINE = UInt8(ascii: "9") + let MINUS = UInt8(ascii: "-") + + func parseTypedNumber(_ string: String) throws -> (Any, IndexDistance)? { + let scan = Scanner(string: string) + var decimal = Decimal() + guard scan.scanDecimal(&decimal) else { + return nil } + return (NSDecimalNumber(decimal: decimal), scan.scanLocation) } - + + // Validate the first few characters look like a JSON encoded number: + // Optional '-' sign at start only 1 leading zero if followed by a decimal point. + var index = input + func nextDigit() -> UInt8? { + guard let (ascii, nextIndex) = source.takeASCII(index) else { return nil } + index = nextIndex + return ascii + } + + guard var digit = nextDigit() else { return nil } + guard digit == MINUS || (digit >= ZERO && digit <= NINE) else { return nil } + if digit == MINUS { + guard let d = nextDigit() else { return nil } + digit = d + } + + if digit == ZERO { + if let digit2 = nextDigit(), digit2 >= ZERO && digit2 <= NINE { + throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue, + userInfo: ["NSDebugDescription" : "Leading zeros not allowed character \(input)." ]) + } + } else if digit < ONE || digit > NINE { + throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue, + userInfo: ["NSDebugDescription" : "Numbers must start with a 1-9 at \(input)." ]) + } + if source.encoding == .utf8 { - return parseTypedNumber(source.buffer.baseAddress!.advanced(by: input), count: source.buffer.count - input).map { return ($0.0, input + $0.1) } + let count = source.buffer.count - input + let ptr = UnsafeMutableRawPointer(mutating: source.buffer.baseAddress!.advanced(by: input)) + guard let string = String(bytesNoCopy: ptr, length: count, + encoding: .utf8, freeWhenDone: false) else { return nil } + return try parseTypedNumber(string).map { return ($0.0, input + $0.1) } } else { - var numberCharacters = [UInt8]() + var string = "" var index = input while let (ascii, nextIndex) = source.takeASCII(index), JSONReader.numberCodePoints.contains(ascii) { - numberCharacters.append(ascii) + string.append(String(UnicodeScalar(ascii))) index = nextIndex } - numberCharacters.append(0) - - return numberCharacters.withUnsafeBufferPointer { - parseTypedNumber($0.baseAddress!, count: $0.count) - }.map { return ($0.0, index) } + return try parseTypedNumber(string).map { return ($0.0, index) } } } diff --git a/TestFoundation/TestJSONEncoder.swift b/TestFoundation/TestJSONEncoder.swift index 509bd1e01c..f0a0dc6896 100644 --- a/TestFoundation/TestJSONEncoder.swift +++ b/TestFoundation/TestJSONEncoder.swift @@ -499,6 +499,104 @@ class TestJSONEncoder : XCTestCase { } } + func test_numericLimits() { + struct DataStruct: Codable { + let int8Value: Int8? + let uint8Value: UInt8? + let int16Value: Int16? + let uint16Value: UInt16? + let int32Value: Int32? + let uint32Value: UInt32? + let int64Value: Int64? + let intValue: Int? + let uintValue: UInt? + let uint64Value: UInt64? + let floatValue: Float? + let doubleValue: Double? + } + + func decode(_ type: String, _ value: String) throws { + var key = type.lowercased() + key.append("Value") + _ = try JSONDecoder().decode(DataStruct.self, from: "{ \"\(key)\": \(value) }".data(using: .utf8)!) + } + + func testGoodValue(_ type: String, _ value: String) { + do { + try decode(type, value) + } catch { + XCTFail("Unexpected error: \(error) for parsing \(value) to \(type)") + } + } + + func testErrorThrown(_ type: String, _ value: String, errorMessage: String) { + do { + try decode(type, value) + XCTFail("Decode of \(value) to \(type) should not succeed") + } catch DecodingError.dataCorrupted(let context) { + XCTAssertEqual(context.debugDescription, errorMessage) + } catch { + XCTAssertEqual(String(describing: error), errorMessage) + } + } + + + var goodValues = [ + ("Int8", "0"), ("Int8", "1"), ("Int8", "-1"), ("Int8", "-128"), ("Int8", "127"), + ("UInt8", "0"), ("UInt8", "1"), ("UInt8", "255"), ("UInt8", "-0"), + + ("Int16", "0"), ("Int16", "1"), ("Int16", "-1"), ("Int16", "-32768"), ("Int16", "32767"), + ("UInt16", "0"), ("UInt16", "1"), ("UInt16", "65535"), ("UInt16", "34.0"), + + ("Int32", "0"), ("Int32", "1"), ("Int32", "-1"), ("Int32", "-2147483648"), ("Int32", "2147483647"), + ("UInt32", "0"), ("UInt32", "1"), ("UInt32", "4294967295"), + + ("Int64", "0"), ("Int64", "1"), ("Int64", "-1"), ("Int64", "-9223372036854775808"), ("Int64", "9223372036854775807"), + ("UInt64", "0"), ("UInt64", "1"), ("UInt64", "18446744073709551615"), + ] + + if Int.max == Int64.max { + goodValues += [ + ("Int", "0"), ("Int", "1"), ("Int", "-1"), ("Int", "-9223372036854775808"), ("Int", "9223372036854775807"), + ("UInt", "0"), ("UInt", "1"), ("UInt", "18446744073709551615"), + ] + } else { + goodValues += [ + ("Int", "0"), ("Int", "1"), ("Int", "-1"), ("Int", "-2147483648"), ("Int", "2147483647"), + ("UInt", "0"), ("UInt", "1"), ("UInt", "4294967295"), + ] + } + + let badValues = [ + ("Int8", "-129"), ("Int8", "128"), ("Int8", "1.2"), + ("UInt8", "-1"), ("UInt8", "256"), + + ("Int16", "-32769"), ("Int16", "32768"), + ("UInt16", "-1"), ("UInt16", "65536"), + + ("Int32", "-2147483649"), ("Int32", "2147483648"), + ("UInt32", "-1"), ("UInt32", "4294967296"), + + ("Int64", "-9223372036854775809"), ("Int64", "9223372036854775808"), ("Int64", "-100000000000000000000"), + ("UInt64", "-1"), ("UInt64", "18446744073709551616"), ("Int64", "10000000000000000000000000000000000000"), + ] + + for value in goodValues { + testGoodValue(value.0, value.1) + } + + for (type, value) in badValues { + testErrorThrown(type, value, errorMessage: "Parsed JSON number <\(value)> does not fit in \(type).") + } + + // Leading zeros are invalid + testErrorThrown("Int8", "0000000000000000000000000000001", errorMessage: "The operation could not be completed") + testErrorThrown("Double", "-.1", errorMessage: "The operation could not be completed") + testErrorThrown("Int32", "+1", errorMessage: "The operation could not be completed") + testErrorThrown("Int", ".012", errorMessage: "The operation could not be completed") + } + + // MARK: - Helper Functions private var _jsonEmptyDictionary: Data { return "{}".data(using: .utf8)! @@ -1089,6 +1187,7 @@ extension TestJSONEncoder { ("test_codingOfDouble", test_codingOfDouble), ("test_codingOfString", test_codingOfString), ("test_codingOfURL", test_codingOfURL), + ("test_numericLimits", test_numericLimits), ] } }