diff --git a/Sources/Turf/JSON.swift b/Sources/Turf/JSON.swift index 2f298d4c..5c9d1005 100644 --- a/Sources/Turf/JSON.swift +++ b/Sources/Turf/JSON.swift @@ -67,12 +67,42 @@ extension JSONValue: RawRepresentable { public init?(rawValue: Any) { // Like `JSONSerialization.jsonObject(with:options:)` with `JSONSerialization.ReadingOptions.fragmentsAllowed` specified. - if let bool = rawValue as? Bool { - self = .boolean(bool) - } else if let string = rawValue as? String { + if let string = rawValue as? String { self = .string(string) } else if let number = rawValue as? NSNumber { - self = .number(number.doubleValue) + /// When a Swift Bool or Objective-C BOOL is boxed with NSNumber, the value of the + /// resulting NSNumber's objCType property is 'c' (Int8 (aka CChar) in Swift, char in + /// Objective-C) and the value is 0 for false/NO and 1 for true/YES. + /// + /// Strictly speaking, an NSNumber with those characteristics can be created by boxing + /// other non-boolean values (e.g. boxing 0 or 1 using the `init(value: CChar)` + /// initializer). Moreover, NSNumber doesn't guarantee to preserve the type suggested + /// by the initializer that's used to create it. + /// + /// This means that when these values are encountered, it is ambiguous whether to + /// decode to JSONValue.number or JSONValue.boolean. + /// + /// In practice, choosing .boolean yields the desired result more often since it is more + /// common to work with Bool than it is Int8. + switch String(cString: number.objCType) { + case "c": // char + if number.int8Value == 0 { + self = .boolean(false) + } else if number.int8Value == 1 { + self = .boolean(true) + } else { + self = .number(number.doubleValue) + } + default: + self = .number(number.doubleValue) + } + } else if let boolean = rawValue as? Bool { + /// This branch must happen after the `NSNumber` branch + /// to avoid converting `NSNumber` instances with values + /// 0 and 1 but of objCType != 'c' to `Bool` since `as? Bool` + /// can succeed when the NSNumber's value is 0 or 1 even + /// when its objCType is not 'c'. + self = .boolean(boolean) } else if let rawArray = rawValue as? JSONArray.RawValue, let array = JSONArray(rawValue: rawArray) { self = .array(array) diff --git a/Tests/TurfTests/JSONTests.swift b/Tests/TurfTests/JSONTests.swift index de66c68a..e1dc4124 100644 --- a/Tests/TurfTests/JSONTests.swift +++ b/Tests/TurfTests/JSONTests.swift @@ -8,6 +8,10 @@ class JSONTests: XCTestCase { XCTAssertEqual(JSONValue(rawValue: 3.1415 as NSNumber), .number(3.1415)) XCTAssertEqual(JSONValue(rawValue: false as NSNumber), .boolean(false)) XCTAssertEqual(JSONValue(rawValue: true as NSNumber), .boolean(true)) + XCTAssertEqual(JSONValue(rawValue: false), .boolean(false)) + XCTAssertEqual(JSONValue(rawValue: true), .boolean(true)) + XCTAssertEqual(JSONValue(rawValue: 0 as NSNumber), .number(0)) + XCTAssertEqual(JSONValue(rawValue: 1 as NSNumber), .number(1)) XCTAssertEqual(JSONValue(rawValue: ["Jason", 42, 3.1415, false, true, nil, [], [:]] as NSArray), .array(["Jason", 42, 3.1415, false, true, nil, [], [:]])) XCTAssertEqual(JSONValue(rawValue: [ @@ -245,5 +249,13 @@ class JSONTests: XCTestCase { XCTAssertEqual(decodedValue?.rawValue as? NSDictionary, rawObject as NSDictionary) XCTAssertNoThrow(try JSONEncoder().encode(decodedValue)) + + // check decoding of 0/1 true/false to ensure unwanted conversions are avoided + let rawString = "[0, 1, true, false]" + // force-unwrap is safe since we control the input + let serializedArrayFromString = rawString.data(using: .utf8)! + XCTAssertNoThrow(decodedValue = try JSONDecoder().decode(JSONValue.self, from: serializedArrayFromString)) + XCTAssertNotNil(decodedValue) + XCTAssertEqual(.array([.number(0), .number(1), .boolean(true), .boolean(false)]), decodedValue) } }