Skip to content

Commit 1e32417

Browse files
committed
JSONSerialization: Improve parsing of numbers
- Fully validate that the number conforms to the JSON number specification. - Determine if the number should be parsed as a UInt64, Int64 or Decimal before falling back to Decimal.
1 parent ff5f81e commit 1e32417

File tree

3 files changed

+148
-49
lines changed

3 files changed

+148
-49
lines changed

Foundation/JSONSerialization.swift

+125-46
Original file line numberDiff line numberDiff line change
@@ -816,66 +816,145 @@ private struct JSONReader {
816816
}
817817

818818
//MARK: - Number parsing
819-
static let numberCodePoints: [UInt8] = [
820-
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, // 0...9
821-
0x2E, 0x2D, 0x2B, 0x45, 0x65, // . - + E e
822-
]
819+
private static let ZERO = UInt8(ascii: "0")
820+
private static let ONE = UInt8(ascii: "1")
821+
private static let NINE = UInt8(ascii: "9")
822+
private static let MINUS = UInt8(ascii: "-")
823+
private static let PLUS = UInt8(ascii: "+")
824+
private static let LOWER_EXPONENT = UInt8(ascii: "e")
825+
private static let UPPER_EXPONENT = UInt8(ascii: "E")
826+
private static let DECIMAL_SEPARATOR = UInt8(ascii: ".")
827+
private static let allDigits = (ZERO...NINE)
828+
private static let oneToNine = (ONE...NINE)
829+
830+
private static let numberCodePoints: [UInt8] = {
831+
var numberCodePoints = Array(ZERO...NINE)
832+
numberCodePoints.append(contentsOf: [DECIMAL_SEPARATOR, MINUS, PLUS, LOWER_EXPONENT, UPPER_EXPONENT])
833+
return numberCodePoints
834+
}()
835+
823836

824837
func parseNumber(_ input: Index, options opt: JSONSerialization.ReadingOptions) throws -> (Any, Index)? {
825-
let ZERO = UInt8(ascii: "0")
826-
let ONE = UInt8(ascii: "1")
827-
let NINE = UInt8(ascii: "9")
828-
let MINUS = UInt8(ascii: "-")
829838

830839
var isNegative = false
831840
var string = ""
832-
833-
// Validate the first few characters look like a JSON encoded number:
834-
// Optional '-' sign at start only 1 leading zero if followed by a decimal point.
841+
var isInteger = true
842+
var exponent = 0
843+
var positiveExponent = true
835844
var index = input
836-
func nextASCII() -> UInt8? {
837-
guard let (ascii, nextIndex) = source.takeASCII(index),
838-
JSONReader.numberCodePoints.contains(ascii) else { return nil }
839-
index = nextIndex
840-
return ascii
841-
}
845+
var digitCount: Int?
846+
var ascii: UInt8 = 0 // set by nextASCII()
847+
848+
// Validate the input is a valid JSON number, also gather the following
849+
// about the input: isNegative, isInteger, the exponent and if it is +/-,
850+
// and finally the count of digits including excluding an '.'
851+
func checkJSONNumber() throws -> Bool {
852+
// Return true if the next character is any one of the valid JSON number characters
853+
func nextASCII() -> Bool {
854+
guard let (ch, nextIndex) = source.takeASCII(index),
855+
JSONReader.numberCodePoints.contains(ch) else { return false }
856+
857+
index = nextIndex
858+
ascii = ch
859+
string.append(Character(UnicodeScalar(ascii)))
860+
return true
861+
}
842862

843-
guard var ascii = nextASCII() else { return nil }
844-
guard ascii == MINUS || (ascii >= ZERO && ascii <= NINE) else { return nil }
845-
if ascii == MINUS {
846-
string = "-"
847-
isNegative = true
848-
guard let d = nextASCII() else { return nil }
849-
ascii = d
850-
}
863+
// Consume as many digits as possible and return with the next non-digit
864+
// or nil if end of string.
865+
func readDigits() -> UInt8? {
866+
while let (ch, nextIndex) = source.takeASCII(index) {
867+
if !JSONReader.allDigits.contains(ch) {
868+
return ch
869+
}
870+
string.append(Character(UnicodeScalar(ch)))
871+
index = nextIndex
872+
}
873+
return nil
874+
}
875+
876+
guard nextASCII() else { return false }
877+
878+
if ascii == JSONReader.MINUS {
879+
isNegative = true
880+
guard nextASCII() else { return false }
881+
}
882+
883+
if JSONReader.oneToNine.contains(ascii) {
884+
guard let ch = readDigits() else { return true }
885+
ascii = ch
886+
if [ JSONReader.DECIMAL_SEPARATOR, JSONReader.LOWER_EXPONENT, JSONReader.UPPER_EXPONENT ].contains(ascii) {
887+
guard nextASCII() else { return false } // There should be at least one char as readDigits didnt remove the '.eE'
888+
}
889+
} else if ascii == JSONReader.ZERO {
890+
guard nextASCII() else { return true }
891+
} else {
892+
throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue,
893+
userInfo: ["NSDebugDescription" : "Numbers must start with a 1-9 at character \(input)." ])
894+
}
895+
896+
if ascii == JSONReader.DECIMAL_SEPARATOR {
897+
isInteger = false
898+
guard readDigits() != nil else { return true }
899+
guard nextASCII() else { return true }
900+
} else if JSONReader.allDigits.contains(ascii) {
901+
throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue,
902+
userInfo: ["NSDebugDescription" : "Leading zeros not allowed at character \(input)." ])
903+
}
851904

852-
if ascii == ZERO {
853-
if let ascii2 = nextASCII() {
854-
if ascii2 >= ZERO && ascii2 <= NINE {
855-
throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue,
856-
userInfo: ["NSDebugDescription" : "Leading zeros not allowed at character \(input)." ])
905+
digitCount = string.count - (isInteger ? 0 : 1) - (isNegative ? 1 : 0)
906+
guard ascii == JSONReader.LOWER_EXPONENT || ascii == JSONReader.UPPER_EXPONENT else {
907+
// End of valid number characters
908+
return true
909+
}
910+
digitCount = digitCount! - 1
911+
912+
// Process the exponent
913+
isInteger = false
914+
guard nextASCII() else { return false }
915+
if ascii == JSONReader.MINUS {
916+
positiveExponent = false
917+
guard nextASCII() else { return false }
918+
} else if ascii == JSONReader.PLUS {
919+
positiveExponent = true
920+
guard nextASCII() else { return false }
921+
}
922+
guard JSONReader.allDigits.contains(ascii) else { return false }
923+
exponent = Int(ascii - JSONReader.ZERO)
924+
while nextASCII() {
925+
guard JSONReader.allDigits.contains(ascii) else { return false } // Invalid exponent character
926+
exponent = (exponent * 10) + Int(ascii - JSONReader.ZERO)
927+
if exponent > 324 {
928+
// Exponent is too large to store in a Double
929+
return false
857930
}
858-
string.append("0")
859-
ascii = ascii2
860931
}
861-
} else if ascii < ONE || ascii > NINE {
862-
throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue,
863-
userInfo: ["NSDebugDescription" : "Numbers must start with a 1-9 at character \(input)." ])
864-
}
865-
string.append(Character(UnicodeScalar(ascii)))
866-
while let ascii = nextASCII() {
867-
string.append(Character(UnicodeScalar(ascii)))
932+
return true
868933
}
869934

870-
if isNegative {
871-
if let intValue = Int64(string) {
872-
return (NSNumber(value: intValue), index)
873-
}
874-
} else {
875-
if let uintValue = UInt64(string) {
876-
return (NSNumber(value: uintValue), index)
935+
guard try checkJSONNumber() == true else { return nil }
936+
digitCount = digitCount ?? string.count - (isInteger ? 0 : 1) - (isNegative ? 1 : 0)
937+
938+
// Try Int64() or UInt64() first
939+
if isInteger {
940+
if isNegative {
941+
if digitCount! <= 19, let intValue = Int64(string) {
942+
return (NSNumber(value: intValue), index)
943+
}
944+
} else {
945+
if digitCount! <= 20, let uintValue = UInt64(string) {
946+
return (NSNumber(value: uintValue), index)
947+
}
877948
}
878949
}
950+
951+
// Decimal holds more digits of precision but a smaller exponent than Double
952+
// so try that if the exponent fits and there are more digits than Double can hold
953+
if digitCount! > 17 && exponent >= -128 && exponent <= 127,
954+
let decimal = Decimal(string: string), decimal.isFinite {
955+
return (NSDecimalNumber(decimal: decimal), index)
956+
}
957+
// Fall back to Double() for everything else
879958
if let doubleValue = Double(string) {
880959
return (NSNumber(value: doubleValue), index)
881960
}

TestFoundation/TestJSONEncoder.swift

+10-1
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ class TestJSONEncoder : XCTestCase {
513513
let uint64Value: UInt64?
514514
let floatValue: Float?
515515
let doubleValue: Double?
516+
let decimalValue: Decimal?
516517
}
517518

518519
func decode(_ type: String, _ value: String) throws {
@@ -553,6 +554,13 @@ class TestJSONEncoder : XCTestCase {
553554

554555
("Int64", "0"), ("Int64", "1"), ("Int64", "-1"), ("Int64", "-9223372036854775808"), ("Int64", "9223372036854775807"),
555556
("UInt64", "0"), ("UInt64", "1"), ("UInt64", "18446744073709551615"),
557+
558+
("Double", "0"), ("Double", "1"), ("Double", "-1"), ("Double", "2.2250738585072014e-308"), ("Double", "1.7976931348623157e+308"),
559+
("Double", "5e-324"), ("Double", "3.141592653589793"),
560+
561+
("Decimal", "1.2"), ("Decimal", "3.14159265358979323846264338327950288419"),
562+
("Decimal", "3402823669209384634633746074317682114550000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
563+
("Decimal", "-3402823669209384634633746074317682114550000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
556564
]
557565

558566
if Int.max == Int64.max {
@@ -589,11 +597,12 @@ class TestJSONEncoder : XCTestCase {
589597
testErrorThrown(type, value, errorMessage: "Parsed JSON number <\(value)> does not fit in \(type).")
590598
}
591599

592-
// Leading zeros are invalid
600+
// Invalid JSON number formats
593601
testErrorThrown("Int8", "0000000000000000000000000000001", errorMessage: "The operation could not be completed")
594602
testErrorThrown("Double", "-.1", errorMessage: "The operation could not be completed")
595603
testErrorThrown("Int32", "+1", errorMessage: "The operation could not be completed")
596604
testErrorThrown("Int", ".012", errorMessage: "The operation could not be completed")
605+
testErrorThrown("Double", "2.7976931348623158e+308", errorMessage: "The operation could not be completed")
597606
}
598607

599608

TestFoundation/TestJSONSerialization.swift

+13-2
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ extension TestJSONSerialization {
487487

488488
//MARK: - Number parsing
489489
func deserialize_numbers(objectType: ObjectType) {
490-
let subject = "[1, -1, 1.3, -1.3, 1e3, 1E-3, 10]"
490+
let subject = "[1, -1, 1.3, -1.3, 1e3, 1E-3, 10, -12.34e56, 12.34e-56, 12.34e+6, 0.002, 0.0043e+4]"
491491

492492
do {
493493
for encoding in supportedEncodings {
@@ -504,14 +504,19 @@ extension TestJSONSerialization {
504504
XCTAssertEqual(result?[5] as? Double, 0.001)
505505
XCTAssertEqual(result?[6] as? Int, 10)
506506
XCTAssertEqual(result?[6] as? Double, 10.0)
507+
XCTAssertEqual(result?[7] as? Double, -12.34e56)
508+
XCTAssertEqual(result?[8] as? Double, 12.34e-56)
509+
XCTAssertEqual(result?[9] as? Double, 12.34e6)
510+
XCTAssertEqual(result?[10] as? Double, 2e-3)
511+
XCTAssertEqual(result?[11] as? Double, 43)
507512
}
508513
} catch {
509514
XCTFail("Unexpected error: \(error)")
510515
}
511516
}
512517

513518
func deserialize_numbers_as_reference_types(objectType: ObjectType) {
514-
let subject = "[1, -1, 1.3, -1.3, 1e3, 1E-3, 10]"
519+
let subject = "[1, -1, 1.3, -1.3, 1e3, 1E-3, 10, -12.34e56, 12.34e-56, 12.34e+6, 0.002, 0.0043e+4]"
515520

516521
do {
517522
for encoding in supportedEncodings {
@@ -528,6 +533,12 @@ extension TestJSONSerialization {
528533
XCTAssertEqual(result?[5] as? NSNumber, 0.001)
529534
XCTAssertEqual(result?[6] as? NSNumber, 10)
530535
XCTAssertEqual(result?[6] as? NSNumber, 10.0)
536+
XCTAssertEqual(result?[7] as? NSNumber, -12.34e56)
537+
XCTAssertEqual(result?[8] as? NSNumber, 12.34e-56)
538+
XCTAssertEqual(result?[9] as? NSNumber, 12.34e6)
539+
XCTAssertEqual(result?[10] as? NSNumber, 2e-3)
540+
XCTAssertEqual(result?[11] as? NSNumber, 43)
541+
531542
}
532543
} catch {
533544
XCTFail("Unexpected error: \(error)")

0 commit comments

Comments
 (0)