Data equality checks have changed in iOS 17

Data equality checks have incorrectly changed on iOS 17.

Step to reproduce:

  1. Run XCTestCase file in iOS 16. It will pass.
  2. Run XCTestCase file in iOS 17. It will fail.

Suspected Cause:

  • In iOS 17, Data has changed how it performs equality. Previously equality did not care about ordered keys, now it does.
  • I've noticed if I modify the test case to use a JSONEncoder setting of encoder.outputFormatting = .sortedKeys, then it passes.

Reason this is a bug:

  • This is unexpected and changed behavior. Equality should not care what the ordering of the keys are when comparing one data object to another data object. Ordering is irrelevant for a JSON object. It's the content that matters. This will cause a huge regression for us in our previously released app if this hits production.

XCTestCase:

import XCTest

final class JSONDecodingTest: XCTestCase {
    
    struct Address: Codable {
        let street: String
        let city: String
        let state: String
        let postalCode: String
    }

    struct OrderItem: Codable {
        let productID: String
        let productName: String
        let quantity: Int
    }

    struct Order: Codable {
        let orderID: String
        let totalAmount: Double
        let items: [OrderItem]
    }

    struct User: Codable {
        let id: Int
        let username: String
        let email: String
        let address: Address
        let orders: [Order]
    }

    struct RootObject: Codable {
        let user: User
    }

    
    func testData() throws {
        
        let object1Json: String = """
        {
          "user": {
            "id": 12345,
            "username": "johndoe",
            "email": "john.doe@example.com",
            "address": {
              "street": "456 Elm St",
              "city": "Somewhere",
              "state": "NY",
              "postalCode": "54321"
            },
            "orders": [
              {
                "orderID": "A987",
                "totalAmount": 75.99,
                "items": [
                  {
                    "productID": "P123",
                    "productName": "Widget",
                    "quantity": 2
                  },
                  {
                    "productID": "P456",
                    "productName": "Gizmo",
                    "quantity": 1
                  }
                ]
              },
              {
                "orderID": "B543",
                "totalAmount": 32.50,
                "items": [
                  {
                    "productID": "P789",
                    "productName": "Thingamajig",
                    "quantity": 3
                  }
                ]
              }
            ]
          }
        }

        """
        let encoder = JSONEncoder()
        encoder.outputFormatting = .sortedKeys
        
        let itemData = object1Json.data(using: .utf8)!
        let item = try JSONDecoder().decode(RootObject.self, from: itemData)
        
        let data1 = try encoder.encode(item)
        let data2 = try encoder.encode(item)
        
        
        XCTAssertTrue(data1 == data2)
    }
}

I've filed feedback here: https://feedbackassistant.apple.com/feedback/12995492

I noticed that in WWDC, Apple said they rewrote the entire JSONEncoder & JSONDecoder implementations. This is a massive regression: https://developer.apple.com/videos/play/wwdc2023/10164/

Post not yet marked as solved Up vote post of sami.taha Down vote post of sami.taha
1.9k views

Replies

In iOS 17, Data has changed how it performs equality.

Actually what apparently changed is the behavior of JSON encoding, which now produces Data objects that are indeed different in your example. Remember that a Data object is just a blob of bytes with no concept of JSON keys.

I've noticed if I modify the test case to use a JSONEncoder setting of encoder.outputFormatting = .sortedKeys, then it passes.

That’s the crucial clue. If you don’t use .sortedKeys then the order of the keys after encoding is undefined. And it’s important to never make assumptions about undefined behavior. Unfortunately this test case makes such an assumption: that the undefined key order will happen to be the same if you perform the same encoding twice.

So in iOS 16 and earlier it (apparently) just so happens that the keys are ordered the same when encoding twice in row, but in iOS 17 it just so happens that they are not. Both behaviors conform to the documented behavior. The bug is the test case’s unintentional reliance on the undefined key order.

I noticed that in WWDC, Apple said they rewrote the entire JSONEncoder & JSONDecoder implementations [in Swift].

This may be the root of the behavior change. Swift's Dictionary can have different (undefined) key orderings even between dictionaries that have identical contents, whereas this may not be the case for Objective-C’s NSDictionary. And if the new JSONEncoder implementation in Swift uses Dictionary internally, well, there you go.

Scott nailed the answer here but I want to provide a little more context.

Swift deliberately introduced some randomness into its hashing. This was specifically designed to prevent hash flooding attacks. It has the added benefits of highlighting problems like this, where folks mistakenly relying on dictionary key ordering.

So far problems like this seem to turn up mostly in test code, which is nice.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

@eskimo For your suggestion box: a writeup on identifying and avoiding unsafe assumptions in general. Here are just a few varied examples I’ve collected on the forum:

  • Good suggestion. I will definitely zen on this.

Add a Comment