Test iOS secure archive decoding on a cloud Mac

Test iOS secure archive decoding on a cloud Mac

Test iOS secure archive decoding on a cloud Mac

The most dangerous outcome when a new app version reads an archive left by an older version is not an error. It is returning empty data, then saving it over the original file. Validate this path on a cloud Mac: first run a Foundation-only decoding test, then have an iOS test target read the same samples. The goal is not to demonstrate one successful round trip, but to prove that incorrect types, unknown versions, and corrupt input are explicitly rejected.

Define the decoding boundary first

This article covers object archives produced by NSKeyedArchiver, not encryption. The example allows a single root object containing a version number and a text field. Establish three requirements before testing:

supportsSecureCoding is a type declaration, not an automatic check of application fields. The reader must still enable requiresSecureCoding, and every object field must be decoded using a method that restricts its allowed types.

Secure decoding restricts which object types can be instantiated. It does not authenticate the source, provide confidentiality, or enforce a file-size quota. Limit the input size before passing data to the decoder.

Write a decoder that rejects invalid input

Save the following as archive-check.swift. It depends only on Foundation; first confirm that xcrun swift --version runs successfully. The version number in this example identifies the archive schema, not the operating system version.

import Foundation

final class Memo: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool { true }
    let schema: Int
    let text: String

    init(schema: Int = 1, text: String) {
        self.schema = schema
        self.text = text
        super.init()
    }

    required init?(coder: NSCoder) {
        guard coder.containsValue(forKey: "schema"),
              coder.decodeInteger(forKey: "schema") == 1,
              let value = coder.decodeObject(
                  of: NSString.self, forKey: "text"
              ),
              value.length <= 4096 else {
            return nil
        }
        schema = 1
        text = value as String
        super.init()
    }

    func encode(with coder: NSCoder) {
        coder.encode(schema, forKey: "schema")
        coder.encode(text as NSString, forKey: "text")
    }
}

enum ArchiveFailure: Error {
    case invalidSize
    case invalidObject
}

func readMemo(_ data: Data) throws -> Memo {
    guard !data.isEmpty, data.count <= 1_048_576 else {
        throw ArchiveFailure.invalidSize
    }
    let reader = try NSKeyedUnarchiver(forReadingFrom: data)
    reader.requiresSecureCoding = true
    reader.decodingFailurePolicy = .setErrorAndReturn
    let memo = reader.decodeObject(
        of: Memo.self, forKey: NSKeyedArchiveRootObjectKey
    )
    reader.finishDecoding()
    if let error = reader.error {
        throw error
    }
    guard let memo else {
        throw ArchiveFailure.invalidObject
    }
    return memo
}

func pack(_ value: Any) throws -> Data {
    try NSKeyedArchiver.archivedData(
        withRootObject: value, requiringSecureCoding: true
    )
}

The code checks for a missing version field first, so the default value returned by integer decoding cannot hide an absent key. The text limit is measured in UTF-16 code units, matching NSString.length. In production, choose a limit that fits your product's data contract; do not treat the example threshold as a universal security standard.

Set setErrorAndReturn before reading any object. Do not assume an outer do/catch will handle every decoding failure: some invalid-object handling depends on the decoder's failure policy rather than the ordinary Swift error-throwing path.

Build a minimal regression set with four inputs

Append the following code. The valid sample must return the original text; the other three samples must fail. Any unexpected result must cause the validation program to exit with a nonzero status.

func expectRejection(_ data: Data) {
    do {
        _ = try readMemo(data)
    } catch {
        return
    }
    fatalError("Unexpected archive acceptance")
}

let valid = try pack(Memo(text: "draft"))
let restored = try readMemo(valid)
guard restored.text == "draft" else {
    fatalError("Round-trip mismatch")
}

expectRejection(try pack("wrong root" as NSString))
expectRejection(try pack(Memo(schema: 2, text: "future")))
expectRejection(Data(valid.prefix(12)))

Compile the program before running it, so an interpreted execution environment does not affect type names in subsequent samples:

set -eu
xcrun swiftc archive-check.swift -o archive-check
./archive-check

Invalid samples may cause Foundation to print diagnostics to standard error. That alone does not mean the validation program has failed. Judge the result by the process exit status and assertions, not by whether the logs are completely empty. If an incorrect root object is accepted, fix the allowlist first; do not delete the negative case just to make the test pass.

Extend coverage to nested objects and historical samples

Restrict allowed types at the field level

If a real archive contains arrays, allowing NSArray alone does not make its elements safe. Use the class-set overload of decodeObject(of:forKey:) to allow both the container and the expected element classes, then validate item counts, field lengths, and relationships. Do not add a broad class such as NSObject merely to bypass a failure.

Archive type restrictions are no substitute for controlling object-graph complexity. An input-size limit also does not guarantee a bound on memory use after decoding. For untrusted input, add a tightly constrained data format or a separate processing boundary.

Check in actual files from older versions

Commit sanitized archives from older versions as test resources, recording the version that produced them, the expected fields, and the permitted migration results. Dynamically generated samples are useful for checking rejection logic, but they cannot prove that the actual bytes written by an older version remain readable.

A Swift class's runtime name can change when its module changes. Before moving a type or renaming a module, validate the historical samples, then configure class mappings using explicit legacy names. Never read arbitrary class names from the input and automatically allow them. After the command-line tests pass, run the same set of files in the iOS test target to cover differences in the target runtime.

Keep decoding failures out of the save path

Represent decoding as either success or an explicit error; avoid following try? with a fallback empty object. If reading fails, preserve the original file and show a recovery path in the UI. Replace the old file only after migration is complete and the new data has passed a read-back check.

Before merging, check five things: the valid sample's fields match; an incorrect root type is rejected; an unknown version is rejected; truncated input does not trigger a successful write-back; and historical samples pass on the iOS target. Make each negative case a separate test so a failure immediately identifies the violated requirement.

The command-line program on a cloud Mac provides fast feedback, while the iOS tests confirm behavior on the target platform. Only when both layers pass do you have repeatable acceptance evidence for the archive-reading path, rather than a single successful run on a developer's machine.

Frequently asked questions

Is supportsSecureCoding enough?

No. Enable requiresSecureCoding when reading, use class-restricted decodeObject methods, and validate schema versions and field values separately.

Should a decoding failure return an empty list?

Do not treat failure as a successful empty result. Return an explicit error and preserve the original archive so a later save cannot silently overwrite it.

Assign a cloud Mac to your next build

Dedicated physical Mac mini, not a virtual machine. Choose a daily, weekly, monthly, or quarterly rental. Check the console for current availability and configuration details.