Skip to content
Assay

Assay

Property lists

import AssayPlist
@Schema(keys: .snakeCase, formats: .all)
struct Deployment {
@Validate(.min(1), .max(63)) var name: String
var image: String
@Validate(.min(1)) var replicas: Int
var region: String = "eu-west-1"
@Validate(.url) var healthCheck: String?
}
let settings = try Deployment.parse(plist: bytes) // either flavour

There is no .plist in the formats: list, and that is not an oversight. A property list reaches your struct through the same projection YAML, XML and TOML use, so any type that lists one of those three can also parse a plist. formats: [.yaml] is enough; .all is just the common spelling.

PropertyListSerialization is not linked, not referenced and not needed, so you get the same decode on Linux and Windows as you do on a Mac. Binary plists come out at 4.33× Foundation’s PropertyListDecoder and the XML flavour at 1.29×, on the platform where there is a PropertyListDecoder to compare against.

The XML flavour is a document:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>name</key><string>api</string>
<key>image</key><string>img:1</string>
<key>replicas</key><integer>3</integer>
</dict>
</plist>
Deployment(name: "api", image: "img:1", replicas: 3, region: "eu-west-1", healthCheck: nil)

The binary flavour is not a document at all. It is a random-access object graph — a trailer at the end of the file, an offset table, and every value resolved by index through it.

// 'bplist00' + 86 bytes, written by PropertyListSerialization
62 70 6c 69 73 74 30 30 … (an offset table and a trailer)
Deployment(name: "api", image: "img:1", replicas: 3, region: "eu-west-1", healthCheck: nil)

Same call, same struct, same result. If you have a reason to require one encoding, you can say so:

try Deployment.parse(plist: bytes) // either
try Deployment.parse(binaryPlist: bytes) // binary only
try Deployment.parse(xmlPlist: bytes) // XML only

This is not sniffing, and the distinction matters

Section titled “This is not sniffing, and the distinction matters”

Elsewhere on this site there is a hard rule: Assay never guesses a format from bytes. Content negotiation makes you pass accepting: with no default, precisely so no one can hand your server an XML bomb by writing a different Content-Type.

A plist looks like an exception and is not one. You already said “this is a property list”. Binary and XML are two encodings of the one format you named, the same way UTF-8 and UTF-16 are two encodings inside XML — which every XML parser resolves from the bytes without anyone calling it sniffing. The rule being kept is “do not guess the format”, and the format was not guessed.

The discriminator is also exact rather than heuristic: bplist00, eight magic bytes at offset zero. Not a shape somebody recognised.

Every XML plist ever written carries this:

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">

A reader that resolved that SYSTEM identifier would be the textbook XXE, in a format people parse without thinking about it. The XML flavour reuses Assay’s XML parser, which has no code path that fetches an external entity — so the refusal is inherited rather than reimplemented, and there is no second implementation to get it wrong later.

The binary flavour has two attacks that depth limits miss

Section titled “The binary flavour has two attacks that depth limits miss”

Worth your time even if you never write a parser, because between them they explain why the entry point takes a Limits and what it is protecting you from.

Reference cycles. An array whose element reference points at the array itself. Twenty bytes on disk, infinite to read. No syntax forbids it, because the format is a graph and a cycle is a well-formed graph.

The guard is a visiting set on the reference path — pushed on descent, popped on return. Deliberately not a global “seen” set: an object referenced twice from two different branches is shared, which is legal and common, since Foundation’s own writer deduplicates repeated values into exactly that shape. Only an object reached from inside itself is a cycle. The issue code is plist_cycle.

Shared-object amplification. Ten arrays, each holding a thousand references to the one below it. Under a kilobyte on disk, 10³⁰ nodes if you materialise it. No cycle, every reference to a distinct real object, and maxDepth never fires because the depth is ten.

This is the plist spelling of billion laughs, and depth cannot bound it because the expansion is wide rather than deep. The guard is a node budget charged per materialised node against a ceiling derived from the input size: a real document cannot describe more nodes than it has bytes to describe them with, and a bomb can. The issue code is plist_amplification.

Both bombs are constructed byte by byte in the test suite rather than described, and you can go and read them. A test that asserts a limit exists without building the input it bounds is a test that keeps passing when the limit is deleted.

Same carets, same codes, same everything you get elsewhere:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>name</key><string>api</string>
<key>image</key><string>img:1</string>
<key>replicas</key><string>three</string>
</dict>
</plist>
Settings.plist: error: replicas must be an integer, found "three"
1 error

The binary flavour cannot give you a caret, because there is no text to point at. You get the path and the code, which is what a binary format can honestly offer.

  • HTTP bodies — the entry point that does refuse to guess.
  • Limits — every budget, and what each one stops.
  • XML — the parser this borrows.