From 8c3f8aaa29e1b811f8af85218783e0835f5829ee Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:08:23 -0700 Subject: [PATCH 01/17] Fix comment format --- src/deserializer/number.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deserializer/number.rs b/src/deserializer/number.rs index 268aafd..f20255a 100644 --- a/src/deserializer/number.rs +++ b/src/deserializer/number.rs @@ -422,7 +422,7 @@ mod float_tests { #[test] fn can_read_decimal_double_fractional_date() { - // NSDate(timeIntervalSinceReferenceDate: 700000000.523) — fractional dates + // `NSDate(timeIntervalSinceReferenceDate: 700000000.523)``: fractional dates // archive their double with the DECIMAL tag (integral ones use I_32). let data = [ DECIMAL, 0xaa, 0xf1, 0x42, 0x80, 0x93, 0xdc, 0xc4, 0x41, 0x86, From d76066e6d3405801765bb59826a6baa8326bb0a6 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:09:18 -0700 Subject: [PATCH 02/17] Add foundation feature scaffold --- Cargo.toml | 3 +- src/deserializer/foundation.rs | 131 +++++++++++++++++++++++++++++++++ src/deserializer/mod.rs | 2 + 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/deserializer/foundation.rs diff --git a/Cargo.toml b/Cargo.toml index 085ea7d..6e81228 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,5 @@ categories = ["parsing", "parser-implementations", "database"] [dependencies] [features] -std=[] \ No newline at end of file +std=[] +foundation=[] \ No newline at end of file diff --git a/src/deserializer/foundation.rs b/src/deserializer/foundation.rs new file mode 100644 index 0000000..5f7045e --- /dev/null +++ b/src/deserializer/foundation.rs @@ -0,0 +1,131 @@ +/*! +Typed accessors for common Apple [Foundation](https://developer.apple.com/documentation/foundation) classes. + +Enabled by the `foundation` cargo feature. These methods interpret the generic +[`Property`] tree as specific Foundation types (`NSString`, `NSDictionary`, …) +so consumers do not have to re-implement class-name matching (and re-discover +its footguns, e.g. that the data cluster archives as both `NSData` and +`NSMutableData`. + +This feature is purely for convenience: the parser and the [`Property`]/ +[`OutputData`](crate::models::output_data::OutputData) model are unchanged +whether or not it is enabled, and any class not modeled here stays reachable +through [`Property::Object`], so nothing is ever lost. + +Accessors are called on a *group-level* [`Property`]: the value yielded while +iterating an object's properties. +*/ + +use crate::deserializer::iter::Property; + +// Cluster class-name sets. Foundation archives several types as both an immutable +// and a mutable variant (and the data cluster archives as both `NSData` and +// `NSMutableData`); matching all variants in one place keeps the footgun +// centralized. Consumed by the scalar/container accessors added from Phase 2 on. +#[allow(dead_code)] +pub(crate) const STRING_CLASSES: &[&str] = &["NSString", "NSMutableString", "NSAttributedString"]; +#[allow(dead_code)] +pub(crate) const DATA_CLASSES: &[&str] = &["NSData", "NSMutableData"]; +#[allow(dead_code)] +pub(crate) const ARRAY_CLASSES: &[&str] = &["NSArray", "NSMutableArray"]; +#[allow(dead_code)] +pub(crate) const DICT_CLASSES: &[&str] = &["NSDictionary", "NSMutableDictionary"]; +#[allow(dead_code)] +pub(crate) const SET_CLASSES: &[&str] = &["NSSet", "NSMutableSet"]; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// The Objective-C class name of the object this property refers to, if any. + /// + /// Resolves whether `self` is a [`Property::Object`] directly or a + /// [`Property::Group`] whose first element is an object. This is the escape + /// hatch for classes the `foundation` feature does not model: the class name + /// plus the raw subtree (via [`Property::Object`]) are always available, so a + /// consumer can handle the long tail of app-specific classes itself. + #[must_use] + pub fn class_name(&self) -> Option<&'a str> { + match self { + Property::Object { name, .. } => Some(*name), + Property::Group(group) => match group.first()? { + Property::Object { name, .. } => Some(name), + _ => None, + }, + Property::Primitive(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + extern crate std; + + use alloc::{vec, vec::Vec}; + use std::{env::current_dir, fs::File, io::Read}; + + use crate::deserializer::typedstream::TypedStreamDeserializer; + + fn load(name: &str) -> Vec { + let path = current_dir() + .unwrap() + .join("src/test_data/foundation") + .join(name); + let mut file = + File::open(&path).unwrap_or_else(|e| panic!("opening fixture {path:?}: {e}")); + let mut bytes = vec![]; + file.read_to_end(&mut bytes).unwrap(); + bytes + } + + #[test] + fn class_name_resolves_element_classes() { + // Iterating an `NSArray` yields one group per element; each element group's + // class_name() is the element's class, and the bare count group is None. + let bytes = load("NSArray"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let names: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.class_name()) + .collect(); + + assert!(names.contains(&"NSString"), "names: {names:?}"); + assert!(names.contains(&"NSNumber"), "names: {names:?}"); + } + + #[test] + fn class_name_on_direct_object() { + // `class_name()` also works when called on a [`Property::Object`] directly + // (e.g. after stepping into a group with `iter().next()`). + let bytes = load("NSArray"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let object_names: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| match group { + crate::deserializer::iter::Property::Group(g) => g.first(), + _ => None, + }) + .filter_map(|inner| inner.class_name()) + .collect(); + + assert!( + object_names.contains(&"NSString"), + "names: {object_names:?}" + ); + } + + #[test] + fn class_name_is_none_for_primitive() { + // The `NSArray`'s count group is a bare primitive, no class. + let bytes = load("NSArray"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let has_primitive_group = ts + .resolve_properties(root) + .unwrap() + .any(|group| group.class_name().is_none()); + + assert!(has_primitive_group); + } +} diff --git a/src/deserializer/mod.rs b/src/deserializer/mod.rs index c3e1227..305b430 100644 --- a/src/deserializer/mod.rs +++ b/src/deserializer/mod.rs @@ -2,6 +2,8 @@ pub mod constants; pub mod consumed; +#[cfg(feature = "foundation")] +pub mod foundation; pub mod header; pub mod iter; pub mod number; From 23c11a2fca821ddd5ab2286b8a5336fc5b67c947 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:09:26 -0700 Subject: [PATCH 03/17] Add foundation test data --- src/test_data/foundation/NSArray | Bin 0 -> 113 bytes src/test_data/foundation/NSArrayEmpty | Bin 0 -> 49 bytes src/test_data/foundation/NSArrayNested | Bin 0 -> 122 bytes src/test_data/foundation/NSArrayWithNull | Bin 0 -> 91 bytes src/test_data/foundation/NSAttributedString | Bin 0 -> 118 bytes src/test_data/foundation/NSData | Bin 0 -> 60 bytes src/test_data/foundation/NSDate | Bin 0 -> 52 bytes src/test_data/foundation/NSDateFractional | Bin 0 -> 56 bytes src/test_data/foundation/NSDictionary | Bin 0 -> 127 bytes src/test_data/foundation/NSDictionaryNested | Bin 0 -> 189 bytes src/test_data/foundation/NSMutableArray | Bin 0 -> 124 bytes src/test_data/foundation/NSMutableData | Bin 0 -> 75 bytes src/test_data/foundation/NSMutableDictionary | Bin 0 -> 136 bytes src/test_data/foundation/NSMutableSet | Bin 0 -> 85 bytes src/test_data/foundation/NSMutableString | Bin 0 -> 81 bytes src/test_data/foundation/NSSet | Bin 0 -> 77 bytes src/test_data/foundation/NSString | Bin 0 -> 62 bytes src/test_data/foundation/NSURL | Bin 0 -> 95 bytes src/test_data/foundation/NSURLRelative | Bin 0 -> 112 bytes src/test_data/foundation/NumberBool | Bin 0 -> 66 bytes src/test_data/foundation/NumberDouble | Bin 0 -> 74 bytes src/test_data/foundation/NumberFloat | Bin 0 -> 70 bytes src/test_data/foundation/NumberInt | Bin 0 -> 66 bytes src/test_data/foundation/NumberInt64 | Bin 0 -> 74 bytes test_data/generators/foundation.swift | 70 +++++++++++++++++++ test_data/generators/generate.sh | 32 +++++++++ 26 files changed, 102 insertions(+) create mode 100644 src/test_data/foundation/NSArray create mode 100644 src/test_data/foundation/NSArrayEmpty create mode 100644 src/test_data/foundation/NSArrayNested create mode 100644 src/test_data/foundation/NSArrayWithNull create mode 100644 src/test_data/foundation/NSAttributedString create mode 100644 src/test_data/foundation/NSData create mode 100644 src/test_data/foundation/NSDate create mode 100644 src/test_data/foundation/NSDateFractional create mode 100644 src/test_data/foundation/NSDictionary create mode 100644 src/test_data/foundation/NSDictionaryNested create mode 100644 src/test_data/foundation/NSMutableArray create mode 100644 src/test_data/foundation/NSMutableData create mode 100644 src/test_data/foundation/NSMutableDictionary create mode 100644 src/test_data/foundation/NSMutableSet create mode 100644 src/test_data/foundation/NSMutableString create mode 100644 src/test_data/foundation/NSSet create mode 100644 src/test_data/foundation/NSString create mode 100644 src/test_data/foundation/NSURL create mode 100644 src/test_data/foundation/NSURLRelative create mode 100644 src/test_data/foundation/NumberBool create mode 100644 src/test_data/foundation/NumberDouble create mode 100644 src/test_data/foundation/NumberFloat create mode 100644 src/test_data/foundation/NumberInt create mode 100644 src/test_data/foundation/NumberInt64 create mode 100644 test_data/generators/foundation.swift create mode 100755 test_data/generators/generate.sh diff --git a/src/test_data/foundation/NSArray b/src/test_data/foundation/NSArray new file mode 100644 index 0000000000000000000000000000000000000000..56670c30208aaf86cc52fc490640d8360a277e14 GIT binary patch literal 113 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!tNLBSX7i)$Fc!{c1ge=foiVAc F4FK$AD186` literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NSArrayEmpty b/src/test_data/foundation/NSArrayEmpty new file mode 100644 index 0000000000000000000000000000000000000000..71b31358d42ba7993eecde0b75644c1792722367 GIT binary patch literal 49 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!tNLBSX7i)$K Og)?W(p3T(O)&>BJWi1f^ literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NSArrayWithNull b/src/test_data/foundation/NSArrayWithNull new file mode 100644 index 0000000000000000000000000000000000000000..988a9a3242daf53fda389567d5a65d0f1685ee48 GIT binary patch literal 91 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!tNLBSX7i)$KYO6 literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NSDateFractional b/src/test_data/foundation/NSDateFractional new file mode 100644 index 0000000000000000000000000000000000000000..b590f358300cd9d2c425b238bdaeda6bf82d236b GIT binary patch literal 56 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!sZw3l30?;(9*);7wn&um6}|_(AvV7(!A=U LQ^VvtM;zM#M5z_@ literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NSDictionary b/src/test_data/foundation/NSDictionary new file mode 100644 index 0000000000000000000000000000000000000000..6ab0795a46231571897921bf111dfdc3732e7879 GIT binary patch literal 127 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!s8e0l9^nRnV*+fRLRiN!r>R}pOlrFT*A=W z!kEc438(}p6kJl2nU~HurG-(ODch)RQp>dIjAdYoDccZ8gBAIe<|d^U0nK3d3l2-n TDNSVns@H01VJw`@*wzLBmF+H2 literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NSDictionaryNested b/src/test_data/foundation/NSDictionaryNested new file mode 100644 index 0000000000000000000000000000000000000000..668066a007481b6b19ab00aa8d1eb9cb843017b7 GIT binary patch literal 189 zcmXYrK?=e!5Ji)wsGuj&ohMLm9Tx#N;$qqciP)-Ry693Ea{#Fd+5>tFr=`2kpZ6aV zkFOprpYxRZ41!3bBzqbv5ZCOK1p7}|QM z%@PqRQ9q*|(`BAmWDDOma4fCh#gs^18p)f_w`rol;q{I)@X%=n`~c_HLh*KmPs&P7E@5bGVa#Nl1k?!>3N9(i%u8pS+QO*KoSjPjTvC*om(B=g`zK|kCYLa@wlHe*c%6^Xe8IjMTd`MLTjnML|-ZIfE2PvR}SDKrYTEx)O!tNIwmY7qT3S{gbj% TlS>#{TNt%kS{Re3GqwQ$6touv literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NumberDouble b/src/test_data/foundation/NumberDouble new file mode 100644 index 0000000000000000000000000000000000000000..9661076106ea60cf4a4ccc00fccf7cdc7ac90742 GIT binary patch literal 74 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!r>R}SDKrYTEx)O!tNIwmY7qT3S{gbj% YlS>#{TNt%kS{PHNH#2~MLZm|*0ME}DKmY&$ literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NumberFloat b/src/test_data/foundation/NumberFloat new file mode 100644 index 0000000000000000000000000000000000000000..bc9553ef2de2e61a9c6223aacb65bfc0a8af236b GIT binary patch literal 70 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!r>R}SDKrYTEx)O!tNIwmY7qT3S{gbj% XlS>#{TNt%kS{T!&H#0CKIJ5x(b8r|b literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NumberInt b/src/test_data/foundation/NumberInt new file mode 100644 index 0000000000000000000000000000000000000000..dace5121aadca7895bf9a7d4f279bc8a0dd14d7e GIT binary patch literal 66 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!r>R}SDKrYTEx)O!tNIwmY7qT3S{gbj% TlS>#{TNt%kS{O5@YqbFY6&@Ee literal 0 HcmV?d00001 diff --git a/src/test_data/foundation/NumberInt64 b/src/test_data/foundation/NumberInt64 new file mode 100644 index 0000000000000000000000000000000000000000..1e906a2351679315bf21dc2366b2504f3ee0f41e GIT binary patch literal 74 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!r>R}SDKrYTEx)O!tNIwmY7qT3S{gbj% blS>#{TNt%kS{Mtbw=+EJd;a(T|Nm_O?Cc%w literal 0 HcmV?d00001 diff --git a/test_data/generators/foundation.swift b/test_data/generators/foundation.swift new file mode 100644 index 0000000..ceaed87 --- /dev/null +++ b/test_data/generators/foundation.swift @@ -0,0 +1,70 @@ +// Generates the Foundation typedstream test fixtures in src/test_data/foundation/ +// using the *classic* NSArchiver (the typedstream archiver). Run via generate.sh. +// +// One object is archived per process invocation: a class that refuses non-keyed +// archiving raises an NSException that aborts only this run (caught by the script +// as a nonzero exit), instead of taking down the whole batch. +// +// NSArchiver is deprecated but still emits `streamtyped` on current macOS. Only +// Tier-1 Foundation classes are generated here (see FOUNDATION_FEATURE_SCOPING.md); +// AppKit/Tier-2 classes are intentionally excluded. + +import Foundation + +func make(_ key: String) -> Any? { + switch key { + // string cluster + case "NSString": return NSString(string: "Hello, world") + case "NSMutableString": return NSMutableString(string: "Hello, world") + case "NSAttributedString": return NSAttributedString(string: "Hello, world") + // data cluster + case "NSData": return NSData(bytes: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x7F] as [UInt8], length: 6) + case "NSMutableData": return NSMutableData(bytes: [0xDE, 0xAD, 0xBE, 0xEF] as [UInt8], length: 4) + // numbers (exactly-representable values so tests can assert equality) + case "NumberBool": return NSNumber(value: true) + case "NumberInt": return NSNumber(value: Int32(42)) + case "NumberFloat": return NSNumber(value: Float(3.5)) + case "NumberDouble": return NSNumber(value: Double(100.5)) + case "NumberInt64": return NSNumber(value: Int64(-9_000_000_000)) // exercises the 8-byte form + // arrays + case "NSArray": return NSArray(array: [NSString(string: "a"), NSNumber(value: 1), NSString(string: "b")]) + case "NSMutableArray": return NSMutableArray(array: [NSString(string: "x"), NSNumber(value: 2)]) + case "NSArrayEmpty": return NSArray(array: []) + case "NSArrayWithNull": return NSArray(array: [NSString(string: "x"), NSNull(), NSString(string: "y")]) + case "NSArrayNested": return NSArray(array: [NSString(string: "top"), NSArray(array: [NSNumber(value: 1), NSNumber(value: 2)])]) + // dictionaries + case "NSDictionary": return NSDictionary(objects: [NSNumber(value: 1), NSString(string: "v")], forKeys: [NSString(string: "k1"), NSString(string: "k2")]) + case "NSMutableDictionary": + let d = NSMutableDictionary() + d.setObject(NSNumber(value: 9), forKey: NSString(string: "key")) + return d + case "NSDictionaryNested": + return NSDictionary( + objects: [ + NSArray(array: [NSNumber(value: 1), NSNumber(value: 2)]), + NSData(bytes: [0x01, 0x02] as [UInt8], length: 2), + NSNumber(value: 7), + ], + forKeys: [NSString(string: "arr"), NSString(string: "data"), NSString(string: "num")] + ) + // sets + case "NSSet": return NSSet(array: [NSString(string: "s1"), NSString(string: "s2")]) + case "NSMutableSet": return NSMutableSet(array: [NSString(string: "m1")]) + // dates (integral dodges the DECIMAL path; fractional exercises it) + case "NSDate": return NSDate(timeIntervalSinceReferenceDate: 21692800) // unix 1_000_000_000 + case "NSDateFractional": return NSDate(timeIntervalSinceReferenceDate: 700000000.523) + // urls + case "NSURL": return NSURL(string: "https://example.com/path?q=1") + case "NSURLRelative": return NSURL(string: "page.html", relativeTo: URL(string: "https://example.com/dir/")) + default: return nil + } +} + +let args = CommandLine.arguments +guard args.count >= 3, let obj = make(args[1]) else { + FileHandle.standardError.write("skip \(args.count >= 2 ? args[1] : "?")\n".data(using: .utf8)!) + exit(2) +} +let data = NSArchiver.archivedData(withRootObject: obj) +try! data.write(to: URL(fileURLWithPath: args[2])) +print("ok \(args[1]) -> \(data.count) bytes") diff --git a/test_data/generators/generate.sh b/test_data/generators/generate.sh new file mode 100755 index 0000000..f10df71 --- /dev/null +++ b/test_data/generators/generate.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Regenerate the Foundation typedstream test fixtures with the classic `NSArchiver`. +# +# macOS only (requires Swift + Foundation). The emitted fixtures live in +# src/test_data/foundation/ and are committed, so the Rust tests run on any +# platform / in CI; only regeneration needs a Mac. `NSArchiver` is deprecated, so +# `swiftc` prints a deprecation warning. +set -eu + +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +out="$repo/src/test_data/foundation" +mkdir -p "$out" + +bin="$(mktemp -t foundation_gen)" +trap 'rm -f "$bin"' EXIT +swiftc -o "$bin" "$here/foundation.swift" + +keys="NSString NSMutableString NSAttributedString \ +NSData NSMutableData \ +NumberBool NumberInt NumberFloat NumberDouble NumberInt64 \ +NSArray NSMutableArray NSArrayEmpty NSArrayWithNull NSArrayNested \ +NSDictionary NSMutableDictionary NSDictionaryNested \ +NSSet NSMutableSet \ +NSDate NSDateFractional \ +NSURL NSURLRelative" + +for k in $keys; do + "$bin" "$k" "$out/$k" || echo "FAILED: $k" +done + +echo "Fixtures written to $out" From 80a1e3bde7005b576b08aa33956d1926cea4b1b0 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:26:22 -0700 Subject: [PATCH 04/17] Add nested test data --- src/test_data/foundation/NestedAttributed | Bin 0 -> 129 bytes src/test_data/foundation/NestedData | Bin 0 -> 105 bytes src/test_data/foundation/NestedStrings | Bin 0 -> 100 bytes test_data/generators/foundation.swift | 6 ++++++ test_data/generators/generate.sh | 3 ++- 5 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/test_data/foundation/NestedAttributed create mode 100644 src/test_data/foundation/NestedData create mode 100644 src/test_data/foundation/NestedStrings diff --git a/src/test_data/foundation/NestedAttributed b/src/test_data/foundation/NestedAttributed new file mode 100644 index 0000000000000000000000000000000000000000..70b86976854864ade7b7fc8b06a88a0d15766587 GIT binary patch literal 129 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!tNLBSX7i)$9&b|jPl literal 0 HcmV?d00001 diff --git a/test_data/generators/foundation.swift b/test_data/generators/foundation.swift index ceaed87..3c10e0f 100644 --- a/test_data/generators/foundation.swift +++ b/test_data/generators/foundation.swift @@ -56,6 +56,12 @@ func make(_ key: String) -> Any? { // urls case "NSURL": return NSURL(string: "https://example.com/path?q=1") case "NSURLRelative": return NSURL(string: "page.html", relativeTo: URL(string: "https://example.com/dir/")) + // nested fixtures: accessors operate on objects wrapped in a group, so these + // place each cluster variant as an array element (a root object's groups are + // its contents, not a wrapper around it). + case "NestedStrings": return NSArray(array: [NSString(string: "imm"), NSMutableString(string: "mut")]) + case "NestedData": return NSArray(array: [NSData(bytes: [0x01, 0x02] as [UInt8], length: 2), NSMutableData(bytes: [0x03, 0x04, 0x05] as [UInt8], length: 3)]) + case "NestedAttributed": return NSArray(array: [NSAttributedString(string: "styled")]) default: return nil } } diff --git a/test_data/generators/generate.sh b/test_data/generators/generate.sh index f10df71..344c639 100755 --- a/test_data/generators/generate.sh +++ b/test_data/generators/generate.sh @@ -23,7 +23,8 @@ NSArray NSMutableArray NSArrayEmpty NSArrayWithNull NSArrayNested \ NSDictionary NSMutableDictionary NSDictionaryNested \ NSSet NSMutableSet \ NSDate NSDateFractional \ -NSURL NSURLRelative" +NSURL NSURLRelative \ +NestedStrings NestedData NestedAttributed" for k in $keys; do "$bin" "$k" "$out/$k" || echo "FAILED: $k" From d8e51c4ea3be1ebfbc17beb0f89041fa8d781543 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:32:37 -0700 Subject: [PATCH 05/17] Implement typed accessors for `Foundation` classes --- src/deserializer/foundation.rs | 371 +++++++++++++++++++++++++++++++-- 1 file changed, 354 insertions(+), 17 deletions(-) diff --git a/src/deserializer/foundation.rs b/src/deserializer/foundation.rs index 5f7045e..944b9ec 100644 --- a/src/deserializer/foundation.rs +++ b/src/deserializer/foundation.rs @@ -5,27 +5,32 @@ Enabled by the `foundation` cargo feature. These methods interpret the generic [`Property`] tree as specific Foundation types (`NSString`, `NSDictionary`, …) so consumers do not have to re-implement class-name matching (and re-discover its footguns, e.g. that the data cluster archives as both `NSData` and -`NSMutableData`. +`NSMutableData`). This feature is purely for convenience: the parser and the [`Property`]/ [`OutputData`](crate::models::output_data::OutputData) model are unchanged whether or not it is enabled, and any class not modeled here stays reachable through [`Property::Object`], so nothing is ever lost. -Accessors are called on a *group-level* [`Property`]: the value yielded while +Accessors are called on a group-level [`Property`]: the value yielded while iterating an object's properties. */ -use crate::deserializer::iter::Property; +use crate::{ + deserializer::iter::{Property, PropertyIterator}, + models::output_data::OutputData, +}; +// MARK: Names // Cluster class-name sets. Foundation archives several types as both an immutable // and a mutable variant (and the data cluster archives as both `NSData` and // `NSMutableData`); matching all variants in one place keeps the footgun -// centralized. Consumed by the scalar/container accessors added from Phase 2 on. -#[allow(dead_code)] -pub(crate) const STRING_CLASSES: &[&str] = &["NSString", "NSMutableString", "NSAttributedString"]; -#[allow(dead_code)] +// centralized. +pub(crate) const STRING_CLASSES: &[&str] = &["NSString", "NSMutableString"]; +pub(crate) const ATTRIBUTED_STRING_CLASSES: &[&str] = + &["NSAttributedString", "NSMutableAttributedString"]; pub(crate) const DATA_CLASSES: &[&str] = &["NSData", "NSMutableData"]; +// Consumed by the container accessors added in Phase 3. #[allow(dead_code)] pub(crate) const ARRAY_CLASSES: &[&str] = &["NSArray", "NSMutableArray"]; #[allow(dead_code)] @@ -33,6 +38,23 @@ pub(crate) const DICT_CLASSES: &[&str] = &["NSDictionary", "NSMutableDictionary" #[allow(dead_code)] pub(crate) const SET_CLASSES: &[&str] = &["NSSet", "NSMutableSet"]; +/// Extract the backing UTF-8 of a string-cluster object: the first `String` +/// primitive in the object's first data group. +fn backing_string<'a, 'b: 'a>(mut data: PropertyIterator<'a, 'b>) -> Option<&'a str> { + if let Property::Group(group) = data.next()? + && let Some(Property::Primitive(OutputData::String(s))) = group.first() + { + return Some(s); + } + None +} + +// MARK: Property +/// Typed accessors for Foundation classes, implemented as methods on [`Property`] so +/// they are available while iterating any object's properties. Each accessor resolves +/// whether `self` is a [`Property::Group`] whose first element is an object of the +/// expected class(es), or (for scalar types) a group wrapping a bare primitive or +/// an `NSNumber` object wrapping one, and returns the interpreted value if successful. impl<'a, 'b: 'a> Property<'a, 'b> { /// The Objective-C class name of the object this property refers to, if any. /// @@ -52,8 +74,142 @@ impl<'a, 'b: 'a> Property<'a, 'b> { Property::Primitive(_) => None, } } + + // MARK: String + /// The backing string of an `NSString` / `NSMutableString`, or the plain text + /// of an `NSAttributedString` / `NSMutableAttributedString` (its attributes + /// remain reachable through the generic [`Property`] tree). + #[must_use] + pub fn as_string(&self) -> Option<&'a str> { + if let Some(data) = self.object_in_classes(STRING_CLASSES) { + return backing_string(data); + } + // An attributed string stores its backing store as a nested + // `NSString`/`NSMutableString`; its position among the groups varies by + // producer (the attributes dictionary often comes first), so scan. + if let Some(data) = self.object_in_classes(ATTRIBUTED_STRING_CLASSES) { + for prop in data { + if let Property::Group(group) = prop + && let Some(Property::Object { + name, data: inner, .. + }) = group.first() + && STRING_CLASSES.contains(&name) + { + return backing_string(inner); + } + } + } + None + } + + // MARK: Bytes + /// The raw bytes of an `NSData` / `NSMutableData`. + /// + /// crabstep does not interpret the bytes — they may be a `bplist00`, a + /// compressed blob, an image, etc. The caller decides what they are. + #[must_use] + pub fn as_data(&self) -> Option<&'a [u8]> { + let data = self.object_in_classes(DATA_CLASSES)?; + for prop in data { + if let Property::Group(group) = prop { + for child in group { + if let Property::Primitive(OutputData::Array(bytes)) = child { + return Some(bytes); + } + } + } + } + None + } + + // MARK: Boolean + /// An `NSNumber` (or bare primitive) interpreted as a boolean. Returns `None` + /// for integer values other than `0` and `1`. + #[must_use] + pub fn as_bool(&self) -> Option { + match self.as_i64()? { + 0 => Some(false), + 1 => Some(true), + _ => None, + } + } + + // MARK: i64 + /// An `NSNumber` (or bare primitive) interpreted as a signed integer. Accepts + /// either integer encoding; an unsigned value that does not fit `i64` yields + /// `None`. Float/double values are *not* coerced. + #[must_use] + pub fn as_i64(&self) -> Option { + match self.scalar()? { + OutputData::SignedInteger(v) => Some(*v), + OutputData::UnsignedInteger(v) => i64::try_from(*v).ok(), + _ => None, + } + } + + // MARK: u64 + /// An `NSNumber` (or bare primitive) interpreted as an unsigned integer. + /// Accepts either integer encoding; a negative signed value yields `None`. + /// Float/double values are *not* coerced. + #[must_use] + pub fn as_u64(&self) -> Option { + match self.scalar()? { + OutputData::UnsignedInteger(v) => Some(*v), + OutputData::SignedInteger(v) => u64::try_from(*v).ok(), + _ => None, + } + } + + // MARK: f64 + /// An `NSNumber` (or bare primitive) interpreted as a double. Accepts `Float` + /// and `Double`; integer values are *not* coerced. + #[must_use] + pub fn as_f64(&self) -> Option { + match self.scalar()? { + OutputData::Double(v) => Some(*v), + OutputData::Float(v) => Some(f64::from(*v)), + _ => None, + } + } + + // MARK: Helpers + /// If `self` is a group whose first item is an object whose class is in + /// `classes`, return that object's data iterator. + fn object_in_classes(&self, classes: &[&str]) -> Option> { + let Property::Group(group) = self else { + return None; + }; + match group.first()? { + Property::Object { name, data, .. } if classes.contains(&name) => Some(data), + _ => None, + } + } + + /// The underlying scalar value of a group that is either a bare primitive or + /// an `NSNumber` wrapping one. + fn scalar(&self) -> Option<&'b OutputData<'a>> { + let Property::Group(group) = self else { + return None; + }; + match group.first()? { + Property::Primitive(value) => Some(value), + Property::Object { + name: "NSNumber", + mut data, + .. + } => match data.next()? { + Property::Group(inner) => match inner.first()? { + Property::Primitive(value) => Some(value), + _ => None, + }, + _ => None, + }, + _ => None, + } + } } +// MARK: Tests #[cfg(test)] mod tests { extern crate std; @@ -61,13 +217,12 @@ mod tests { use alloc::{vec, vec::Vec}; use std::{env::current_dir, fs::File, io::Read}; + use crate::deserializer::iter::Property; use crate::deserializer::typedstream::TypedStreamDeserializer; - fn load(name: &str) -> Vec { - let path = current_dir() - .unwrap() - .join("src/test_data/foundation") - .join(name); + /// Load a fixture by path relative to `src/test_data`. + fn load(rel: &str) -> Vec { + let path = current_dir().unwrap().join("src/test_data").join(rel); let mut file = File::open(&path).unwrap_or_else(|e| panic!("opening fixture {path:?}: {e}")); let mut bytes = vec![]; @@ -75,11 +230,13 @@ mod tests { bytes } + // MARK: class_name + #[test] fn class_name_resolves_element_classes() { // Iterating an `NSArray` yields one group per element; each element group's - // class_name() is the element's class, and the bare count group is None. - let bytes = load("NSArray"); + // `class_name()` is the element's class, and the bare count group is None. + let bytes = load("foundation/NSArray"); let mut ts = TypedStreamDeserializer::new(&bytes); let root = ts.oxidize().unwrap(); let names: Vec<&str> = ts @@ -96,14 +253,14 @@ mod tests { fn class_name_on_direct_object() { // `class_name()` also works when called on a [`Property::Object`] directly // (e.g. after stepping into a group with `iter().next()`). - let bytes = load("NSArray"); + let bytes = load("foundation/NSArray"); let mut ts = TypedStreamDeserializer::new(&bytes); let root = ts.oxidize().unwrap(); let object_names: Vec<&str> = ts .resolve_properties(root) .unwrap() .filter_map(|group| match group { - crate::deserializer::iter::Property::Group(g) => g.first(), + Property::Group(g) => g.first(), _ => None, }) .filter_map(|inner| inner.class_name()) @@ -118,7 +275,7 @@ mod tests { #[test] fn class_name_is_none_for_primitive() { // The `NSArray`'s count group is a bare primitive, no class. - let bytes = load("NSArray"); + let bytes = load("foundation/NSArray"); let mut ts = TypedStreamDeserializer::new(&bytes); let root = ts.oxidize().unwrap(); let has_primitive_group = ts @@ -128,4 +285,184 @@ mod tests { assert!(has_primitive_group); } + + // MARK: as_string + + #[test] + fn as_string_reads_both_string_variants() { + // NSArray([NSString "imm", NSMutableString "mut"]) + let bytes = load("foundation/NestedStrings"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let strings: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + + assert_eq!(strings.len(), 2, "{strings:?}"); + assert!(strings.contains(&"imm"), "{strings:?}"); + assert!(strings.contains(&"mut"), "{strings:?}"); + } + + #[test] + fn as_string_reads_attributed_backing_text() { + // NSArray([NSAttributedString "styled"]) — exercises the scan path. + let bytes = load("foundation/NestedAttributed"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let strings: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + + assert_eq!(strings, vec!["styled"]); + } + + #[test] + fn as_string_reads_real_mutable_attributed_body() { + // Real Messages NSMutableAttributedString: backing store is an + // NSMutableString reached as one of the root's groups (plain path). + let bytes = load("AttributedBodyTextOnly"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let strings: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + + assert!(strings.contains(&"Noter test"), "{strings:?}"); + } + + // MARK: as_data + + #[test] + fn as_data_reads_both_data_variants() { + // NSArray([NSData [1,2], NSMutableData [3,4,5]]) + let bytes = load("foundation/NestedData"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let datas: Vec<&[u8]> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_data()) + .collect(); + + assert_eq!(datas.len(), 2, "{datas:?}"); + assert!(datas.contains(&&[0x01, 0x02][..]), "{datas:?}"); + assert!(datas.contains(&&[0x03, 0x04, 0x05][..]), "{datas:?}"); + } + + // MARK: numbers + + #[test] + fn as_f64_reads_decimal_double() { + // NumberDouble root: bare-primitive path, exercises the B1 DECIMAL fix. + let bytes = load("foundation/NumberDouble"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_f64(), Some(100.5)); + } + + #[test] + fn as_f64_reads_float() { + let bytes = load("foundation/NumberFloat"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_f64(), Some(3.5)); + } + + #[test] + fn as_i64_reads_large_negative() { + // NumberInt64 = -9_000_000_000: bare-primitive path, exercises the B2 fix. + let bytes = load("foundation/NumberInt64"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_i64(), Some(-9_000_000_000)); + } + + #[test] + fn integer_coercion_and_strictness() { + // NumberInt = SignedInteger(42). + let bytes = load("foundation/NumberInt"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_i64(), Some(42)); + assert_eq!(group.as_u64(), Some(42)); // non-negative signed coerces to u64 + assert_eq!(group.as_f64(), None); // no silent int -> float + assert_eq!(group.as_string(), None); // a number is not a string + assert_eq!(group.as_data(), None); + } + + #[test] + fn unsigned_coercion_rejects_negative() { + let bytes = load("foundation/NumberInt64"); // -9_000_000_000 + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_u64(), None); + } + + // MARK: as_bool + #[test] + fn as_bool_reads_boolean() { + let bytes = load("foundation/NumberBool"); // NSNumber(true) -> SignedInteger(1) + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_bool(), Some(true)); + assert_eq!(group.as_i64(), Some(1)); + } + + // MARK: wrappers + + #[test] + fn unwraps_nsnumber_object_and_reads_dict_entries() { + // NSDictionaryNested = { "arr": [..], "data": , "num": NSNumber(7) }. + // Keys are NSString objects; the NSNumber value exercises the unwrap path; + // the NSData value exercises as_data on a dictionary value. + let bytes = load("foundation/NSDictionaryNested"); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let keys: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + assert!( + keys.contains(&"arr") && keys.contains(&"data") && keys.contains(&"num"), + "{keys:?}" + ); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let ints: Vec = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_i64()) + .collect(); + assert!(ints.contains(&7), "{ints:?}"); // NSNumber(7) value, unwrapped + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let datas: Vec<&[u8]> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_data()) + .collect(); + assert!(datas.contains(&&[0x01, 0x02][..]), "{datas:?}"); // NSData value + } } From f8f1d3436a2add7a265c0c0caf879803d00d55e8 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:43:00 -0700 Subject: [PATCH 06/17] Add nested container tests --- src/test_data/foundation/NestedContainers | Bin 0 -> 287 bytes test_data/generators/foundation.swift | 14 ++++++++++++++ test_data/generators/generate.sh | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 src/test_data/foundation/NestedContainers diff --git a/src/test_data/foundation/NestedContainers b/src/test_data/foundation/NestedContainers new file mode 100644 index 0000000000000000000000000000000000000000..3ad54ffb94a7379942223daae30949ffc0403e77 GIT binary patch literal 287 zcmYk0%MQUn6o%&%L5PjF;3Y_GER4k@_ERQO)MYwdbSXLd0KJ4RBx2{yJcgN0iQUQh zzyCX@-U*Zx+*i?2tj_Nm!Uz$Yl#Qk25kYKGHg`8-smKMvYbKcHPNEULr=jl(sTqwg z_%0MALl_{!sem}a>#Zo9SPzIj%4VVB?oL>3nuE=2CQoxxQeDa>-cosoJ(m&53{&1= zN_zV>X!#JfmAGxi{h#=+`7q7r-SNM0%fb!Hm{8`)X$e4J_xf)2orzzMs;$%b106(h AU;qFB literal 0 HcmV?d00001 diff --git a/test_data/generators/foundation.swift b/test_data/generators/foundation.swift index 3c10e0f..7252b76 100644 --- a/test_data/generators/foundation.swift +++ b/test_data/generators/foundation.swift @@ -62,6 +62,20 @@ func make(_ key: String) -> Any? { case "NestedStrings": return NSArray(array: [NSString(string: "imm"), NSMutableString(string: "mut")]) case "NestedData": return NSArray(array: [NSData(bytes: [0x01, 0x02] as [UInt8], length: 2), NSMutableData(bytes: [0x03, 0x04, 0x05] as [UInt8], length: 3)]) case "NestedAttributed": return NSArray(array: [NSAttributedString(string: "styled")]) + case "NestedContainers": + // Nested containers (both cluster variants + an empty array) so the + // container accessors can be tested on objects wrapped in a group. + let md = NSMutableDictionary() + md.setObject(NSNumber(value: 8), forKey: NSString(string: "mk")) + return NSArray(array: [ + NSArray(array: [NSNumber(value: 1), NSNumber(value: 2)]), + NSMutableArray(array: [NSNumber(value: 3)]), + NSArray(array: []), + NSDictionary(objects: [NSNumber(value: 9)], forKeys: [NSString(string: "k")]), + md, + NSSet(array: [NSString(string: "s")]), + NSMutableSet(array: [NSString(string: "ms")]), + ]) default: return nil } } diff --git a/test_data/generators/generate.sh b/test_data/generators/generate.sh index 344c639..ee1c7a3 100755 --- a/test_data/generators/generate.sh +++ b/test_data/generators/generate.sh @@ -24,7 +24,7 @@ NSDictionary NSMutableDictionary NSDictionaryNested \ NSSet NSMutableSet \ NSDate NSDateFractional \ NSURL NSURLRelative \ -NestedStrings NestedData NestedAttributed" +NestedStrings NestedData NestedAttributed NestedContainers" for k in $keys; do "$bin" "$k" "$out/$k" || echo "FAILED: $k" From 19b8df56281af6012c96435eae1852dab7f75784 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:49:49 -0700 Subject: [PATCH 07/17] Add accessors for Foundation collections: arrays, sets, and dictionaries --- src/deserializer/foundation.rs | 170 ++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 4 deletions(-) diff --git a/src/deserializer/foundation.rs b/src/deserializer/foundation.rs index 944b9ec..b4cf782 100644 --- a/src/deserializer/foundation.rs +++ b/src/deserializer/foundation.rs @@ -26,16 +26,18 @@ use crate::{ // and a mutable variant (and the data cluster archives as both `NSData` and // `NSMutableData`); matching all variants in one place keeps the footgun // centralized. +/// Denotes string data pub(crate) const STRING_CLASSES: &[&str] = &["NSString", "NSMutableString"]; +/// Denotes string data with attributes (e.g. font, color, …) pub(crate) const ATTRIBUTED_STRING_CLASSES: &[&str] = &["NSAttributedString", "NSMutableAttributedString"]; +/// Denotes raw bytes pub(crate) const DATA_CLASSES: &[&str] = &["NSData", "NSMutableData"]; -// Consumed by the container accessors added in Phase 3. -#[allow(dead_code)] +/// Denotes ordered collections of arbitrary objects. pub(crate) const ARRAY_CLASSES: &[&str] = &["NSArray", "NSMutableArray"]; -#[allow(dead_code)] +/// Denotes unordered collections of arbitrary objects. pub(crate) const DICT_CLASSES: &[&str] = &["NSDictionary", "NSMutableDictionary"]; -#[allow(dead_code)] +/// Denotes unordered collections of unique arbitrary objects. pub(crate) const SET_CLASSES: &[&str] = &["NSSet", "NSMutableSet"]; /// Extract the backing UTF-8 of a string-cluster object: the first `String` @@ -172,6 +174,41 @@ impl<'a, 'b: 'a> Property<'a, 'b> { } } + // MARK: Array + /// The elements of an `NSArray` / `NSMutableArray` as a lazy iterator. The + /// leading element-count group is skipped; each yielded [`Property`] is an + /// element on which the other accessors (`as_string`, `as_i64`, a nested + /// `as_array`, …) can be called. + #[must_use] + pub fn as_array(&self) -> Option> { + let mut data = self.object_in_classes(ARRAY_CLASSES)?; + data.next(); // discard the count group + Some(FoundationArray { inner: data }) + } + + // MARK: Set + /// The members of an `NSSet` / `NSMutableSet` as a lazy iterator (unordered). + /// Shares [`FoundationArray`] with [`as_array`](Self::as_array): the count + /// group is skipped and each member is yielded as a [`Property`]. + #[must_use] + pub fn as_set(&self) -> Option> { + let mut data = self.object_in_classes(SET_CLASSES)?; + data.next(); // discard the count group + Some(FoundationArray { inner: data }) + } + + // MARK: Dictionary + /// The key/value pairs of an `NSDictionary` / `NSMutableDictionary` as a lazy + /// iterator. The leading count group is skipped and the remaining groups are + /// paired `(key, value)`; a trailing unpaired key (only reachable from + /// malformed data) is dropped. + #[must_use] + pub fn as_dictionary(&self) -> Option> { + let mut data = self.object_in_classes(DICT_CLASSES)?; + data.next(); // discard the count group + Some(FoundationDict { inner: data }) + } + // MARK: Helpers /// If `self` is a group whose first item is an object whose class is in /// `classes`, return that object's data iterator. @@ -209,6 +246,43 @@ impl<'a, 'b: 'a> Property<'a, 'b> { } } +// MARK: Iterators +/// A lazy iterator over the elements of an `NSArray` / `NSMutableArray` (or the +/// members of an `NSSet` / `NSMutableSet`), produced by [`Property::as_array`] / +/// [`Property::as_set`]. Each item is the element's group-level [`Property`], so +/// the other accessors apply to it directly. +#[derive(Debug, Clone)] +pub struct FoundationArray<'a, 'b> { + inner: PropertyIterator<'a, 'b>, +} + +impl<'a, 'b: 'a> Iterator for FoundationArray<'a, 'b> { + type Item = Property<'a, 'b>; + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +/// A lazy iterator over the `(key, value)` pairs of an `NSDictionary` / +/// `NSMutableDictionary`, produced by [`Property::as_dictionary`]. Each key and +/// value is a group-level [`Property`]. +#[derive(Debug, Clone)] +pub struct FoundationDict<'a, 'b> { + inner: PropertyIterator<'a, 'b>, +} + +impl<'a, 'b: 'a> Iterator for FoundationDict<'a, 'b> { + type Item = (Property<'a, 'b>, Property<'a, 'b>); + + fn next(&mut self) -> Option { + let key = self.inner.next()?; + // A missing value means an unpaired trailing key (malformed data); drop it. + let value = self.inner.next()?; + Some((key, value)) + } +} + // MARK: Tests #[cfg(test)] mod tests { @@ -465,4 +539,92 @@ mod tests { .collect(); assert!(datas.contains(&&[0x01, 0x02][..]), "{datas:?}"); // NSData value } + + // MARK: as_array / as_set + + #[test] + fn as_array_yields_elements_both_variants_and_empty() { + // NestedContainers root holds NSArray[1,2], NSMutableArray[3], an empty + // NSArray, then non-array elements (dicts/sets) which as_array ignores. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let arrays: Vec> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_array()) + .map(|array| array.filter_map(|el| el.as_i64()).collect()) + .collect(); + + assert_eq!(arrays, vec![vec![1, 2], vec![3], vec![]]); + } + + #[test] + fn as_set_yields_members_both_variants() { + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let sets: Vec> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_set()) + .map(|set| set.filter_map(|member| member.as_string()).collect()) + .collect(); + + assert_eq!(sets, vec![vec!["s"], vec!["ms"]]); + } + + // MARK: as_dictionary + + #[test] + fn as_dictionary_yields_pairs_both_variants() { + // NestedContainers holds NSDictionary{k:9} and NSMutableDictionary{mk:8}. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let entries: Vec<(&str, i64)> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_dictionary()) + .flat_map(|dict| { + dict.filter_map(|(key, value)| Some((key.as_string()?, value.as_i64()?))) + .collect::>() + }) + .collect(); + + assert_eq!(entries.len(), 2, "{entries:?}"); + assert!(entries.contains(&("k", 9)), "{entries:?}"); + assert!(entries.contains(&("mk", 8)), "{entries:?}"); + } + + #[test] + fn nested_array_inside_array() { + // NSArrayNested = [NSString "top", NSArray[1, 2]] — as_array on the nested + // element resolves the inner array. + let bytes = load("foundation/NSArrayNested"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let inner: Vec> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_array()) + .map(|array| array.filter_map(|el| el.as_i64()).collect()) + .collect(); + + assert_eq!(inner, vec![vec![1, 2]]); + } + + // MARK: container negatives + + #[test] + fn container_accessors_reject_non_containers() { + let bytes = load("foundation/NumberInt"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert!(group.as_array().is_none()); + assert!(group.as_set().is_none()); + assert!(group.as_dictionary().is_none()); + } } From 7d5b469eefea495af336a2b337b4e2e2d077680b Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:57:15 -0700 Subject: [PATCH 08/17] Add support for NestedScalars in Foundation typedstream test fixtures --- src/test_data/foundation/NestedScalars | Bin 0 -> 207 bytes test_data/generators/foundation.swift | 9 +++++++++ test_data/generators/generate.sh | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/test_data/foundation/NestedScalars diff --git a/src/test_data/foundation/NestedScalars b/src/test_data/foundation/NestedScalars new file mode 100644 index 0000000000000000000000000000000000000000..ebf2348b945f1ede704c657850779ad1be8ceb6d GIT binary patch literal 207 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!tNLBSX7i)$U5=>?Q@qrqHONuh{(iwq5+AB?ZM+ z`ueFAiMa(isd~xzx%vf(B^maGwuWs$17^%*1Q9^3vt~ Any? { NSSet(array: [NSString(string: "s")]), NSMutableSet(array: [NSString(string: "ms")]), ]) + case "NestedScalars": + // NSDate / NSURL (absolute + relative) / NSNull as array elements, so the + // Phase 4 accessors can be tested on objects wrapped in a group. + return NSArray(array: [ + NSDate(timeIntervalSinceReferenceDate: 21692800), // unix 1_000_000_000 + NSURL(string: "https://example.com/path?q=1")!, + NSURL(string: "page.html", relativeTo: URL(string: "https://example.com/dir/"))!, + NSNull(), + ]) default: return nil } } diff --git a/test_data/generators/generate.sh b/test_data/generators/generate.sh index ee1c7a3..9279932 100755 --- a/test_data/generators/generate.sh +++ b/test_data/generators/generate.sh @@ -24,7 +24,7 @@ NSDictionary NSMutableDictionary NSDictionaryNested \ NSSet NSMutableSet \ NSDate NSDateFractional \ NSURL NSURLRelative \ -NestedStrings NestedData NestedAttributed NestedContainers" +NestedStrings NestedData NestedAttributed NestedContainers NestedScalars" for k in $keys; do "$bin" "$k" "$out/$k" || echo "FAILED: $k" From 211bfd730068ba3dd3ca93b8aa13c4faf47c9d4d Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 14:57:54 -0700 Subject: [PATCH 09/17] Add date, URL, and null accessors for Foundation properties --- src/deserializer/foundation.rs | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/deserializer/foundation.rs b/src/deserializer/foundation.rs index b4cf782..846d788 100644 --- a/src/deserializer/foundation.rs +++ b/src/deserializer/foundation.rs @@ -209,6 +209,62 @@ impl<'a, 'b: 'a> Property<'a, 'b> { Some(FoundationDict { inner: data }) } + // MARK: Date + /// An `NSDate` as seconds since the Cocoa reference epoch (2001-01-01 00:00:00 + /// UTC). Use [`as_unix_time`](Self::as_unix_time) for seconds since the Unix + /// epoch. + #[must_use] + pub fn as_date(&self) -> Option { + let mut data = self.object_in_classes(&["NSDate"])?; + match data.next()? { + Property::Group(group) => match group.first()? { + Property::Primitive(OutputData::Double(seconds)) => Some(*seconds), + _ => None, + }, + _ => None, + } + } + + /// An `NSDate` as seconds since the Unix epoch (1970-01-01 00:00:00 UTC), + /// i.e. [`as_date`](Self::as_date)` + 978_307_200.0` (the offset between the + /// Unix and Cocoa reference epochs). + #[must_use] + pub fn as_unix_time(&self) -> Option { + self.as_date().map(|seconds| seconds + 978_307_200.0) + } + + // MARK: URL + /// The string of an `NSURL`. For an absolute URL this is the full URL; for a + /// URL created relative to a base, it is the relative component (the base + /// `NSURL` remains reachable through the generic [`Property`] tree). + #[must_use] + pub fn as_url(&self) -> Option<&'a str> { + let data = self.object_in_classes(&["NSURL"])?; + for group in data { + if let Some(url) = group.as_string() { + return Some(url); + } + } + None + } + + // MARK: Null + /// Whether this property is an `NSNull` instance or a nil object reference + /// ([`OutputData::Null`]). + #[must_use] + pub fn is_null(&self) -> bool { + match self { + Property::Object { name: "NSNull", .. } | Property::Primitive(OutputData::Null) => true, + Property::Group(group) => matches!( + group.first(), + Some( + Property::Object { name: "NSNull", .. } | Property::Primitive(OutputData::Null) + ) + ), + _ => false, + } + } + // MARK: Helpers /// If `self` is a group whose first item is an object whose class is in /// `classes`, return that object's data iterator. @@ -627,4 +683,77 @@ mod tests { assert!(group.as_set().is_none()); assert!(group.as_dictionary().is_none()); } + + // MARK: as_date / as_unix_time + + #[test] + fn as_date_and_unix_time() { + // NestedScalars holds NSDate(timeIntervalSinceReferenceDate: 21692800). + let bytes = load("foundation/NestedScalars"); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let dates: Vec = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_date()) + .collect(); + assert_eq!(dates, vec![21692800.0]); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let unix: Vec = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_unix_time()) + .collect(); + assert_eq!(unix, vec![1_000_000_000.0]); + } + + // MARK: as_url + + #[test] + fn as_url_absolute_and_relative() { + // NestedScalars holds an absolute NSURL then a relative one; the relative + // one yields its relative component. + let bytes = load("foundation/NestedScalars"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let urls: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_url()) + .collect(); + + assert_eq!(urls, vec!["https://example.com/path?q=1", "page.html"]); + } + + // MARK: is_null + + #[test] + fn is_null_detects_nsnull() { + let bytes = load("foundation/NestedScalars"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let null_count = ts + .resolve_properties(root) + .unwrap() + .filter(|group| group.is_null()) + .count(); + + assert_eq!(null_count, 1); + } + + #[test] + fn scalar_accessors_reject_wrong_types() { + let bytes = load("foundation/NumberInt"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert!(!group.is_null()); + assert_eq!(group.as_date(), None); + assert_eq!(group.as_unix_time(), None); + assert_eq!(group.as_url(), None); + } } From 31476444fd32b2f1c83f0dd78a17d3c73161d059 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 15:40:07 -0700 Subject: [PATCH 10/17] Include new crate features in ci tests --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be41d2..61086be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - run: rustup update stable && rustup default stable - - run: cargo test --verbose + - run: cargo test --verbose --all-features - run: | export VERSION=${{ github.event.release.tag_name }} sed -i "s/0.0.0/$VERSION/g" Cargo.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4001a7e..83a4e69 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,5 +17,5 @@ jobs: steps: - uses: actions/checkout@v4 - run: rustup update stable && rustup default stable - - run: cargo clippy - - run: cargo test --verbose + - run: cargo clippy --all-targets --all-features + - run: cargo test --verbose --all-features From 56101c60122a8a4a667ab2d69ffbf255152431f2 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 15:47:47 -0700 Subject: [PATCH 11/17] Improve Foundation accessors with lazy views for arrays and dictionaries --- Cargo.toml | 8 +- README.md | 13 + src/deserializer/foundation.rs | 423 ++++++++++++++++++++++++++++++--- src/deserializer/mod.rs | 1 + src/lib.rs | 1 + 5 files changed, 411 insertions(+), 35 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6e81228..3cc6a10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,5 +14,9 @@ categories = ["parsing", "parser-implementations", "database"] [dependencies] [features] -std=[] -foundation=[] \ No newline at end of file +std = [] +foundation = [] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/README.md b/README.md index 67c66e1..4dfb3cb 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,19 @@ The `typedstream` format is derived from the data structure used by `NeXTSTEP`'s - Robust error handling for malformed or incomplete `typedstream` data - Ergonomic `TypedStreamDeserializer` with `resolve_properties` iterator for exploring object graphs +## Feature Flags + +`crabstep` is `no_std` by default and requires no dependencies. The following optional features are purely additive: + +- `std` — enables `std`-only conveniences, such as `print_resolved` for debugging an object graph. +- `foundation` — adds typed accessors on `Property` for common Apple Foundation classes (`as_string`, `as_data`, `as_array`, `as_dictionary`, `as_date`, `as_url`, and more), so consumers do not have to hand-roll class-name matching. See the `deserializer::foundation` module. + +Enable a feature in your `Cargo.toml`: + +```toml +crabstep = { version = "0", features = ["foundation"] } +``` + ## Reverse Engineering A blog post describing the reverse engineering of `typedstream` is available as [an in-depth article](https://chrissardegna.com/blog/reverse-engineering-apples-typedstream-format/). diff --git a/src/deserializer/foundation.rs b/src/deserializer/foundation.rs index 846d788..34e0f76 100644 --- a/src/deserializer/foundation.rs +++ b/src/deserializer/foundation.rs @@ -8,7 +8,7 @@ its footguns, e.g. that the data cluster archives as both `NSData` and `NSMutableData`). This feature is purely for convenience: the parser and the [`Property`]/ -[`OutputData`](crate::models::output_data::OutputData) model are unchanged +[`OutputData`] model are unchanged whether or not it is enabled, and any class not modeled here stays reachable through [`Property::Object`], so nothing is ever lost. @@ -175,38 +175,95 @@ impl<'a, 'b: 'a> Property<'a, 'b> { } // MARK: Array - /// The elements of an `NSArray` / `NSMutableArray` as a lazy iterator. The - /// leading element-count group is skipped; each yielded [`Property`] is an - /// element on which the other accessors (`as_string`, `as_i64`, a nested - /// `as_array`, …) can be called. + /// The elements of an `NSArray` / `NSMutableArray` as a lazy [`FoundationArray`] + /// view (the leading element-count group is skipped). Supports `len` / + /// `get(index)` / `iter`; each element is a group-level [`Property`] on which + /// the other accessors (`as_string`, `as_i64`, a nested `as_array`, …) apply. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let bytes: &[u8] = &[]; // a typedstream payload + /// let mut typedstream = TypedStreamDeserializer::new(bytes); + /// let root = typedstream.oxidize().unwrap(); + /// + /// for property in typedstream.resolve_properties(root).unwrap() { + /// if let Some(array) = property.as_array() { + /// println!("{} elements", array.len()); + /// for element in &array { + /// println!("{:?}", element.as_string()); + /// } + /// } + /// } + /// ``` #[must_use] pub fn as_array(&self) -> Option> { - let mut data = self.object_in_classes(ARRAY_CLASSES)?; - data.next(); // discard the count group - Some(FoundationArray { inner: data }) + let (elements, len) = split_count(self.object_in_classes(ARRAY_CLASSES)?)?; + Some(FoundationArray { elements, len }) } // MARK: Set - /// The members of an `NSSet` / `NSMutableSet` as a lazy iterator (unordered). - /// Shares [`FoundationArray`] with [`as_array`](Self::as_array): the count - /// group is skipped and each member is yielded as a [`Property`]. + /// The members of an `NSSet` / `NSMutableSet` as a lazy [`FoundationArray`] + /// view (unordered). Shares the type with [`as_array`](Self::as_array): the + /// count group is skipped and each member is a group-level [`Property`]. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let bytes: &[u8] = &[]; + /// let mut typedstream = TypedStreamDeserializer::new(bytes); + /// let root = typedstream.oxidize().unwrap(); + /// + /// for property in typedstream.resolve_properties(root).unwrap() { + /// if let Some(set) = property.as_set() { + /// for member in &set { + /// println!("{:?}", member.as_string()); + /// } + /// } + /// } + /// ``` #[must_use] pub fn as_set(&self) -> Option> { - let mut data = self.object_in_classes(SET_CLASSES)?; - data.next(); // discard the count group - Some(FoundationArray { inner: data }) + let (elements, len) = split_count(self.object_in_classes(SET_CLASSES)?)?; + Some(FoundationArray { elements, len }) } // MARK: Dictionary - /// The key/value pairs of an `NSDictionary` / `NSMutableDictionary` as a lazy - /// iterator. The leading count group is skipped and the remaining groups are - /// paired `(key, value)`; a trailing unpaired key (only reachable from - /// malformed data) is dropped. + /// The entries of an `NSDictionary` / `NSMutableDictionary` as a lazy + /// [`FoundationDict`] view (the leading count group is skipped). Look up a + /// string key with [`get`](FoundationDict::get), or iterate the `(key, value)` + /// pairs; each key and value is a group-level [`Property`]. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let bytes: &[u8] = &[]; + /// let mut typedstream = TypedStreamDeserializer::new(bytes); + /// let root = typedstream.oxidize().unwrap(); + /// + /// for property in typedstream.resolve_properties(root).unwrap() { + /// if let Some(dict) = property.as_dictionary() { + /// // Look up a value by its string key. + /// if let Some(part) = dict.get("__kIMMessagePartAttributeName") { + /// println!("part index = {:?}", part.as_i64()); + /// } + /// // Or iterate every entry. + /// for (key, value) in &dict { + /// println!("{:?} => {:?}", key.as_string(), value.as_i64()); + /// } + /// } + /// } + /// ``` #[must_use] pub fn as_dictionary(&self) -> Option> { - let mut data = self.object_in_classes(DICT_CLASSES)?; - data.next(); // discard the count group - Some(FoundationDict { inner: data }) + let (entries, len) = split_count(self.object_in_classes(DICT_CLASSES)?)?; + Some(FoundationDict { entries, len }) } // MARK: Date @@ -303,16 +360,113 @@ impl<'a, 'b: 'a> Property<'a, 'b> { } // MARK: Iterators -/// A lazy iterator over the elements of an `NSArray` / `NSMutableArray` (or the +/// Read the leading count group of a container's data, returning the remaining +/// iterator (positioned at the first element/entry) and the declared count. +fn split_count<'a, 'b: 'a>( + mut data: PropertyIterator<'a, 'b>, +) -> Option<(PropertyIterator<'a, 'b>, usize)> { + let count = data.next()?; + let len = usize::try_from(count.as_i64().unwrap_or(0)).unwrap_or(0); + Some((data, len)) +} + +/// A lazy view over the elements of an `NSArray` / `NSMutableArray` (or the /// members of an `NSSet` / `NSMutableSet`), produced by [`Property::as_array`] / -/// [`Property::as_set`]. Each item is the element's group-level [`Property`], so -/// the other accessors apply to it directly. +/// [`Property::as_set`]. Cheap to clone and queryable any number of times; each +/// element is a group-level [`Property`], so the other accessors apply directly. #[derive(Debug, Clone)] pub struct FoundationArray<'a, 'b> { + elements: PropertyIterator<'a, 'b>, + len: usize, +} + +impl<'a, 'b: 'a> FoundationArray<'a, 'b> { + /// The number of elements (from the archived count). + #[must_use] + pub fn len(&self) -> usize { + self.len + } + + /// Whether the collection has no elements. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// A fresh iterator over the elements. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let array = property.as_array().unwrap(); + /// for element in array.iter() { + /// println!("{:?}", element.as_string()); + /// } + /// ``` + #[must_use] + pub fn iter(&self) -> FoundationArrayIter<'a, 'b> { + FoundationArrayIter { + inner: self.elements.clone(), + } + } + + /// The element at `index` (a linear `O(index)` walk). + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let array = property.as_array().unwrap(); + /// println!("{:?}", array.get(2).and_then(|element| element.as_i64())); + /// ``` + #[must_use] + pub fn get(&self, index: usize) -> Option> { + self.iter().nth(index) + } + + /// The first element. + #[must_use] + pub fn first(&self) -> Option> { + self.iter().next() + } +} + +impl<'a, 'b: 'a> IntoIterator for FoundationArray<'a, 'b> { + type Item = Property<'a, 'b>; + type IntoIter = FoundationArrayIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + FoundationArrayIter { + inner: self.elements, + } + } +} + +impl<'a, 'b: 'a> IntoIterator for &FoundationArray<'a, 'b> { + type Item = Property<'a, 'b>; + type IntoIter = FoundationArrayIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// The iterator yielded by [`FoundationArray::iter`] and its [`IntoIterator`] impl. +#[derive(Debug, Clone)] +pub struct FoundationArrayIter<'a, 'b> { inner: PropertyIterator<'a, 'b>, } -impl<'a, 'b: 'a> Iterator for FoundationArray<'a, 'b> { +impl<'a, 'b: 'a> Iterator for FoundationArrayIter<'a, 'b> { type Item = Property<'a, 'b>; fn next(&mut self) -> Option { @@ -320,15 +474,148 @@ impl<'a, 'b: 'a> Iterator for FoundationArray<'a, 'b> { } } -/// A lazy iterator over the `(key, value)` pairs of an `NSDictionary` / -/// `NSMutableDictionary`, produced by [`Property::as_dictionary`]. Each key and -/// value is a group-level [`Property`]. +/// A lazy view over the `(key, value)` pairs of an `NSDictionary` / +/// `NSMutableDictionary`, produced by [`Property::as_dictionary`]. Cheap to clone +/// and queryable any number of times; each key and value is a group-level +/// [`Property`]. #[derive(Debug, Clone)] pub struct FoundationDict<'a, 'b> { + entries: PropertyIterator<'a, 'b>, + len: usize, +} + +impl<'a, 'b: 'a> FoundationDict<'a, 'b> { + /// The number of entries (from the archived count). + #[must_use] + pub fn len(&self) -> usize { + self.len + } + + /// Whether the dictionary has no entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// A fresh iterator over the `(key, value)` pairs. + #[must_use] + pub fn iter(&self) -> FoundationDictIter<'a, 'b> { + FoundationDictIter { + inner: self.entries.clone(), + } + } + + /// The value for a string `key`, or `None` if absent. + /// + /// This is a linear scan (`O(n)`); dictionaries archived in a `typedstream` + /// are typically small. Only string keys are matched — for other key types, + /// or to build an index for many lookups, use [`iter`](Self::iter). + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// if let Some(value) = dict.get("__kIMMessagePartAttributeName") { + /// println!("{:?}", value.as_i64()); + /// } + /// ``` + #[must_use] + pub fn get(&self, key: &str) -> Option> { + self.iter() + .find_map(|(k, v)| (k.as_string() == Some(key)).then_some(v)) + } + + /// Whether the dictionary contains the given string `key`. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// if dict.contains_key("__kIMMessagePartAttributeName") { + /// // the message-part attribute is present + /// } + /// ``` + #[must_use] + pub fn contains_key(&self, key: &str) -> bool { + self.get(key).is_some() + } + + /// A fresh iterator over the keys. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// for key in dict.keys() { + /// println!("{:?}", key.as_string()); + /// } + /// ``` + pub fn keys(&self) -> impl Iterator> { + self.iter().map(|(k, _)| k) + } + + /// A fresh iterator over the values. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// for value in dict.values() { + /// println!("{:?}", value.as_i64()); + /// } + /// ``` + pub fn values(&self) -> impl Iterator> { + self.iter().map(|(_, v)| v) + } +} + +impl<'a, 'b: 'a> IntoIterator for FoundationDict<'a, 'b> { + type Item = (Property<'a, 'b>, Property<'a, 'b>); + type IntoIter = FoundationDictIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + FoundationDictIter { + inner: self.entries, + } + } +} + +impl<'a, 'b: 'a> IntoIterator for &FoundationDict<'a, 'b> { + type Item = (Property<'a, 'b>, Property<'a, 'b>); + type IntoIter = FoundationDictIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// The iterator yielded by [`FoundationDict::iter`] and its [`IntoIterator`] impl. +#[derive(Debug, Clone)] +pub struct FoundationDictIter<'a, 'b> { inner: PropertyIterator<'a, 'b>, } -impl<'a, 'b: 'a> Iterator for FoundationDict<'a, 'b> { +impl<'a, 'b: 'a> Iterator for FoundationDictIter<'a, 'b> { type Item = (Property<'a, 'b>, Property<'a, 'b>); fn next(&mut self) -> Option { @@ -609,7 +896,7 @@ mod tests { .resolve_properties(root) .unwrap() .filter_map(|group| group.as_array()) - .map(|array| array.filter_map(|el| el.as_i64()).collect()) + .map(|array| array.into_iter().filter_map(|el| el.as_i64()).collect()) .collect(); assert_eq!(arrays, vec![vec![1, 2], vec![3], vec![]]); @@ -624,7 +911,11 @@ mod tests { .resolve_properties(root) .unwrap() .filter_map(|group| group.as_set()) - .map(|set| set.filter_map(|member| member.as_string()).collect()) + .map(|set| { + set.into_iter() + .filter_map(|member| member.as_string()) + .collect() + }) .collect(); assert_eq!(sets, vec![vec!["s"], vec!["ms"]]); @@ -643,7 +934,8 @@ mod tests { .unwrap() .filter_map(|group| group.as_dictionary()) .flat_map(|dict| { - dict.filter_map(|(key, value)| Some((key.as_string()?, value.as_i64()?))) + dict.into_iter() + .filter_map(|(key, value)| Some((key.as_string()?, value.as_i64()?))) .collect::>() }) .collect(); @@ -664,7 +956,7 @@ mod tests { .resolve_properties(root) .unwrap() .filter_map(|group| group.as_array()) - .map(|array| array.filter_map(|el| el.as_i64()).collect()) + .map(|array| array.into_iter().filter_map(|el| el.as_i64()).collect()) .collect(); assert_eq!(inner, vec![vec![1, 2]]); @@ -756,4 +1048,69 @@ mod tests { assert_eq!(group.as_unix_time(), None); assert_eq!(group.as_url(), None); } + + // MARK: container views + + #[test] + fn array_view_len_get_first() { + // First array element of NestedContainers is NSArray[1, 2]. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let array = ts + .resolve_properties(root) + .unwrap() + .find_map(|group| group.as_array()) + .unwrap(); + + assert_eq!(array.len(), 2); + assert!(!array.is_empty()); + assert_eq!(array.first().and_then(|e| e.as_i64()), Some(1)); + assert_eq!(array.get(0).and_then(|e| e.as_i64()), Some(1)); + assert_eq!(array.get(1).and_then(|e| e.as_i64()), Some(2)); + assert!(array.get(2).is_none()); + } + + #[test] + fn array_view_empty() { + // NestedContainers also holds an empty NSArray. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let empty = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_array()) + .find(|array| array.is_empty()) + .unwrap(); + + assert_eq!(empty.len(), 0); + assert!(empty.first().is_none()); + assert_eq!(empty.iter().count(), 0); + } + + #[test] + fn dict_view_get_contains_keys_values() { + // First dictionary of NestedContainers is NSDictionary { "k": 9 }. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let dict = ts + .resolve_properties(root) + .unwrap() + .find_map(|group| group.as_dictionary()) + .unwrap(); + + assert_eq!(dict.len(), 1); + assert!(!dict.is_empty()); + assert_eq!(dict.get("k").and_then(|v| v.as_i64()), Some(9)); + assert!(dict.get("missing").is_none()); + assert!(dict.contains_key("k")); + assert!(!dict.contains_key("missing")); + + let keys: Vec<&str> = dict.keys().filter_map(|k| k.as_string()).collect(); + assert_eq!(keys, vec!["k"]); + let values: Vec = dict.values().filter_map(|v| v.as_i64()).collect(); + assert_eq!(values, vec![9]); + } } diff --git a/src/deserializer/mod.rs b/src/deserializer/mod.rs index 305b430..5b86d8a 100644 --- a/src/deserializer/mod.rs +++ b/src/deserializer/mod.rs @@ -3,6 +3,7 @@ pub mod constants; pub mod consumed; #[cfg(feature = "foundation")] +#[cfg_attr(docsrs, doc(cfg(feature = "foundation")))] pub mod foundation; pub mod header; pub mod iter; diff --git a/src/lib.rs b/src/lib.rs index 0ed20f5..3f9e17d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ #![deny(missing_docs)] #![doc = include_str!("../README.md")] #![no_std] +#![cfg_attr(docsrs, feature(doc_cfg))] extern crate alloc; #[cfg(feature = "std")] extern crate std; From 84002b841a5064241d8c5f08061f9383b9b81081 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 16:12:42 -0700 Subject: [PATCH 12/17] Re-export `Property` --- src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 3f9e17d..0e67d66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,10 @@ pub mod deserializer; pub mod error; pub mod models; -pub use deserializer::{iter::PropertyIterator, typedstream::TypedStreamDeserializer}; +pub use deserializer::{ + iter::{Property, PropertyIterator}, + typedstream::TypedStreamDeserializer, +}; pub use models::{ archived::{Archived, ObjectData}, output_data::OutputData, From 88699cdddbd85b1569a3f431fee585f950b77487 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 16:12:55 -0700 Subject: [PATCH 13/17] Refactor into type modules --- src/deserializer/foundation.rs | 1116 ------------------- src/deserializer/foundation/array.rs | 278 +++++ src/deserializer/foundation/boolean.rs | 33 + src/deserializer/foundation/bytes.rs | 51 + src/deserializer/foundation/class.rs | 87 ++ src/deserializer/foundation/date.rs | 74 ++ src/deserializer/foundation/dict.rs | 247 ++++ src/deserializer/foundation/helpers.rs | 27 + src/deserializer/foundation/mod.rs | 39 + src/deserializer/foundation/names.rs | 19 + src/deserializer/foundation/null.rs | 42 + src/deserializer/foundation/number.rs | 167 +++ src/deserializer/foundation/string.rs | 100 ++ src/deserializer/foundation/test_support.rs | 15 + src/deserializer/foundation/url.rs | 43 + 15 files changed, 1222 insertions(+), 1116 deletions(-) delete mode 100644 src/deserializer/foundation.rs create mode 100644 src/deserializer/foundation/array.rs create mode 100644 src/deserializer/foundation/boolean.rs create mode 100644 src/deserializer/foundation/bytes.rs create mode 100644 src/deserializer/foundation/class.rs create mode 100644 src/deserializer/foundation/date.rs create mode 100644 src/deserializer/foundation/dict.rs create mode 100644 src/deserializer/foundation/helpers.rs create mode 100644 src/deserializer/foundation/mod.rs create mode 100644 src/deserializer/foundation/names.rs create mode 100644 src/deserializer/foundation/null.rs create mode 100644 src/deserializer/foundation/number.rs create mode 100644 src/deserializer/foundation/string.rs create mode 100644 src/deserializer/foundation/test_support.rs create mode 100644 src/deserializer/foundation/url.rs diff --git a/src/deserializer/foundation.rs b/src/deserializer/foundation.rs deleted file mode 100644 index 34e0f76..0000000 --- a/src/deserializer/foundation.rs +++ /dev/null @@ -1,1116 +0,0 @@ -/*! -Typed accessors for common Apple [Foundation](https://developer.apple.com/documentation/foundation) classes. - -Enabled by the `foundation` cargo feature. These methods interpret the generic -[`Property`] tree as specific Foundation types (`NSString`, `NSDictionary`, …) -so consumers do not have to re-implement class-name matching (and re-discover -its footguns, e.g. that the data cluster archives as both `NSData` and -`NSMutableData`). - -This feature is purely for convenience: the parser and the [`Property`]/ -[`OutputData`] model are unchanged -whether or not it is enabled, and any class not modeled here stays reachable -through [`Property::Object`], so nothing is ever lost. - -Accessors are called on a group-level [`Property`]: the value yielded while -iterating an object's properties. -*/ - -use crate::{ - deserializer::iter::{Property, PropertyIterator}, - models::output_data::OutputData, -}; - -// MARK: Names -// Cluster class-name sets. Foundation archives several types as both an immutable -// and a mutable variant (and the data cluster archives as both `NSData` and -// `NSMutableData`); matching all variants in one place keeps the footgun -// centralized. -/// Denotes string data -pub(crate) const STRING_CLASSES: &[&str] = &["NSString", "NSMutableString"]; -/// Denotes string data with attributes (e.g. font, color, …) -pub(crate) const ATTRIBUTED_STRING_CLASSES: &[&str] = - &["NSAttributedString", "NSMutableAttributedString"]; -/// Denotes raw bytes -pub(crate) const DATA_CLASSES: &[&str] = &["NSData", "NSMutableData"]; -/// Denotes ordered collections of arbitrary objects. -pub(crate) const ARRAY_CLASSES: &[&str] = &["NSArray", "NSMutableArray"]; -/// Denotes unordered collections of arbitrary objects. -pub(crate) const DICT_CLASSES: &[&str] = &["NSDictionary", "NSMutableDictionary"]; -/// Denotes unordered collections of unique arbitrary objects. -pub(crate) const SET_CLASSES: &[&str] = &["NSSet", "NSMutableSet"]; - -/// Extract the backing UTF-8 of a string-cluster object: the first `String` -/// primitive in the object's first data group. -fn backing_string<'a, 'b: 'a>(mut data: PropertyIterator<'a, 'b>) -> Option<&'a str> { - if let Property::Group(group) = data.next()? - && let Some(Property::Primitive(OutputData::String(s))) = group.first() - { - return Some(s); - } - None -} - -// MARK: Property -/// Typed accessors for Foundation classes, implemented as methods on [`Property`] so -/// they are available while iterating any object's properties. Each accessor resolves -/// whether `self` is a [`Property::Group`] whose first element is an object of the -/// expected class(es), or (for scalar types) a group wrapping a bare primitive or -/// an `NSNumber` object wrapping one, and returns the interpreted value if successful. -impl<'a, 'b: 'a> Property<'a, 'b> { - /// The Objective-C class name of the object this property refers to, if any. - /// - /// Resolves whether `self` is a [`Property::Object`] directly or a - /// [`Property::Group`] whose first element is an object. This is the escape - /// hatch for classes the `foundation` feature does not model: the class name - /// plus the raw subtree (via [`Property::Object`]) are always available, so a - /// consumer can handle the long tail of app-specific classes itself. - #[must_use] - pub fn class_name(&self) -> Option<&'a str> { - match self { - Property::Object { name, .. } => Some(*name), - Property::Group(group) => match group.first()? { - Property::Object { name, .. } => Some(name), - _ => None, - }, - Property::Primitive(_) => None, - } - } - - // MARK: String - /// The backing string of an `NSString` / `NSMutableString`, or the plain text - /// of an `NSAttributedString` / `NSMutableAttributedString` (its attributes - /// remain reachable through the generic [`Property`] tree). - #[must_use] - pub fn as_string(&self) -> Option<&'a str> { - if let Some(data) = self.object_in_classes(STRING_CLASSES) { - return backing_string(data); - } - // An attributed string stores its backing store as a nested - // `NSString`/`NSMutableString`; its position among the groups varies by - // producer (the attributes dictionary often comes first), so scan. - if let Some(data) = self.object_in_classes(ATTRIBUTED_STRING_CLASSES) { - for prop in data { - if let Property::Group(group) = prop - && let Some(Property::Object { - name, data: inner, .. - }) = group.first() - && STRING_CLASSES.contains(&name) - { - return backing_string(inner); - } - } - } - None - } - - // MARK: Bytes - /// The raw bytes of an `NSData` / `NSMutableData`. - /// - /// crabstep does not interpret the bytes — they may be a `bplist00`, a - /// compressed blob, an image, etc. The caller decides what they are. - #[must_use] - pub fn as_data(&self) -> Option<&'a [u8]> { - let data = self.object_in_classes(DATA_CLASSES)?; - for prop in data { - if let Property::Group(group) = prop { - for child in group { - if let Property::Primitive(OutputData::Array(bytes)) = child { - return Some(bytes); - } - } - } - } - None - } - - // MARK: Boolean - /// An `NSNumber` (or bare primitive) interpreted as a boolean. Returns `None` - /// for integer values other than `0` and `1`. - #[must_use] - pub fn as_bool(&self) -> Option { - match self.as_i64()? { - 0 => Some(false), - 1 => Some(true), - _ => None, - } - } - - // MARK: i64 - /// An `NSNumber` (or bare primitive) interpreted as a signed integer. Accepts - /// either integer encoding; an unsigned value that does not fit `i64` yields - /// `None`. Float/double values are *not* coerced. - #[must_use] - pub fn as_i64(&self) -> Option { - match self.scalar()? { - OutputData::SignedInteger(v) => Some(*v), - OutputData::UnsignedInteger(v) => i64::try_from(*v).ok(), - _ => None, - } - } - - // MARK: u64 - /// An `NSNumber` (or bare primitive) interpreted as an unsigned integer. - /// Accepts either integer encoding; a negative signed value yields `None`. - /// Float/double values are *not* coerced. - #[must_use] - pub fn as_u64(&self) -> Option { - match self.scalar()? { - OutputData::UnsignedInteger(v) => Some(*v), - OutputData::SignedInteger(v) => u64::try_from(*v).ok(), - _ => None, - } - } - - // MARK: f64 - /// An `NSNumber` (or bare primitive) interpreted as a double. Accepts `Float` - /// and `Double`; integer values are *not* coerced. - #[must_use] - pub fn as_f64(&self) -> Option { - match self.scalar()? { - OutputData::Double(v) => Some(*v), - OutputData::Float(v) => Some(f64::from(*v)), - _ => None, - } - } - - // MARK: Array - /// The elements of an `NSArray` / `NSMutableArray` as a lazy [`FoundationArray`] - /// view (the leading element-count group is skipped). Supports `len` / - /// `get(index)` / `iter`; each element is a group-level [`Property`] on which - /// the other accessors (`as_string`, `as_i64`, a nested `as_array`, …) apply. - /// - /// # Examples - /// - /// ```no_run - /// use crabstep::TypedStreamDeserializer; - /// - /// let bytes: &[u8] = &[]; // a typedstream payload - /// let mut typedstream = TypedStreamDeserializer::new(bytes); - /// let root = typedstream.oxidize().unwrap(); - /// - /// for property in typedstream.resolve_properties(root).unwrap() { - /// if let Some(array) = property.as_array() { - /// println!("{} elements", array.len()); - /// for element in &array { - /// println!("{:?}", element.as_string()); - /// } - /// } - /// } - /// ``` - #[must_use] - pub fn as_array(&self) -> Option> { - let (elements, len) = split_count(self.object_in_classes(ARRAY_CLASSES)?)?; - Some(FoundationArray { elements, len }) - } - - // MARK: Set - /// The members of an `NSSet` / `NSMutableSet` as a lazy [`FoundationArray`] - /// view (unordered). Shares the type with [`as_array`](Self::as_array): the - /// count group is skipped and each member is a group-level [`Property`]. - /// - /// # Examples - /// - /// ```no_run - /// use crabstep::TypedStreamDeserializer; - /// - /// let bytes: &[u8] = &[]; - /// let mut typedstream = TypedStreamDeserializer::new(bytes); - /// let root = typedstream.oxidize().unwrap(); - /// - /// for property in typedstream.resolve_properties(root).unwrap() { - /// if let Some(set) = property.as_set() { - /// for member in &set { - /// println!("{:?}", member.as_string()); - /// } - /// } - /// } - /// ``` - #[must_use] - pub fn as_set(&self) -> Option> { - let (elements, len) = split_count(self.object_in_classes(SET_CLASSES)?)?; - Some(FoundationArray { elements, len }) - } - - // MARK: Dictionary - /// The entries of an `NSDictionary` / `NSMutableDictionary` as a lazy - /// [`FoundationDict`] view (the leading count group is skipped). Look up a - /// string key with [`get`](FoundationDict::get), or iterate the `(key, value)` - /// pairs; each key and value is a group-level [`Property`]. - /// - /// # Examples - /// - /// ```no_run - /// use crabstep::TypedStreamDeserializer; - /// - /// let bytes: &[u8] = &[]; - /// let mut typedstream = TypedStreamDeserializer::new(bytes); - /// let root = typedstream.oxidize().unwrap(); - /// - /// for property in typedstream.resolve_properties(root).unwrap() { - /// if let Some(dict) = property.as_dictionary() { - /// // Look up a value by its string key. - /// if let Some(part) = dict.get("__kIMMessagePartAttributeName") { - /// println!("part index = {:?}", part.as_i64()); - /// } - /// // Or iterate every entry. - /// for (key, value) in &dict { - /// println!("{:?} => {:?}", key.as_string(), value.as_i64()); - /// } - /// } - /// } - /// ``` - #[must_use] - pub fn as_dictionary(&self) -> Option> { - let (entries, len) = split_count(self.object_in_classes(DICT_CLASSES)?)?; - Some(FoundationDict { entries, len }) - } - - // MARK: Date - /// An `NSDate` as seconds since the Cocoa reference epoch (2001-01-01 00:00:00 - /// UTC). Use [`as_unix_time`](Self::as_unix_time) for seconds since the Unix - /// epoch. - #[must_use] - pub fn as_date(&self) -> Option { - let mut data = self.object_in_classes(&["NSDate"])?; - match data.next()? { - Property::Group(group) => match group.first()? { - Property::Primitive(OutputData::Double(seconds)) => Some(*seconds), - _ => None, - }, - _ => None, - } - } - - /// An `NSDate` as seconds since the Unix epoch (1970-01-01 00:00:00 UTC), - /// i.e. [`as_date`](Self::as_date)` + 978_307_200.0` (the offset between the - /// Unix and Cocoa reference epochs). - #[must_use] - pub fn as_unix_time(&self) -> Option { - self.as_date().map(|seconds| seconds + 978_307_200.0) - } - - // MARK: URL - /// The string of an `NSURL`. For an absolute URL this is the full URL; for a - /// URL created relative to a base, it is the relative component (the base - /// `NSURL` remains reachable through the generic [`Property`] tree). - #[must_use] - pub fn as_url(&self) -> Option<&'a str> { - let data = self.object_in_classes(&["NSURL"])?; - for group in data { - if let Some(url) = group.as_string() { - return Some(url); - } - } - None - } - - // MARK: Null - /// Whether this property is an `NSNull` instance or a nil object reference - /// ([`OutputData::Null`]). - #[must_use] - pub fn is_null(&self) -> bool { - match self { - Property::Object { name: "NSNull", .. } | Property::Primitive(OutputData::Null) => true, - Property::Group(group) => matches!( - group.first(), - Some( - Property::Object { name: "NSNull", .. } | Property::Primitive(OutputData::Null) - ) - ), - _ => false, - } - } - - // MARK: Helpers - /// If `self` is a group whose first item is an object whose class is in - /// `classes`, return that object's data iterator. - fn object_in_classes(&self, classes: &[&str]) -> Option> { - let Property::Group(group) = self else { - return None; - }; - match group.first()? { - Property::Object { name, data, .. } if classes.contains(&name) => Some(data), - _ => None, - } - } - - /// The underlying scalar value of a group that is either a bare primitive or - /// an `NSNumber` wrapping one. - fn scalar(&self) -> Option<&'b OutputData<'a>> { - let Property::Group(group) = self else { - return None; - }; - match group.first()? { - Property::Primitive(value) => Some(value), - Property::Object { - name: "NSNumber", - mut data, - .. - } => match data.next()? { - Property::Group(inner) => match inner.first()? { - Property::Primitive(value) => Some(value), - _ => None, - }, - _ => None, - }, - _ => None, - } - } -} - -// MARK: Iterators -/// Read the leading count group of a container's data, returning the remaining -/// iterator (positioned at the first element/entry) and the declared count. -fn split_count<'a, 'b: 'a>( - mut data: PropertyIterator<'a, 'b>, -) -> Option<(PropertyIterator<'a, 'b>, usize)> { - let count = data.next()?; - let len = usize::try_from(count.as_i64().unwrap_or(0)).unwrap_or(0); - Some((data, len)) -} - -/// A lazy view over the elements of an `NSArray` / `NSMutableArray` (or the -/// members of an `NSSet` / `NSMutableSet`), produced by [`Property::as_array`] / -/// [`Property::as_set`]. Cheap to clone and queryable any number of times; each -/// element is a group-level [`Property`], so the other accessors apply directly. -#[derive(Debug, Clone)] -pub struct FoundationArray<'a, 'b> { - elements: PropertyIterator<'a, 'b>, - len: usize, -} - -impl<'a, 'b: 'a> FoundationArray<'a, 'b> { - /// The number of elements (from the archived count). - #[must_use] - pub fn len(&self) -> usize { - self.len - } - - /// Whether the collection has no elements. - #[must_use] - pub fn is_empty(&self) -> bool { - self.len == 0 - } - - /// A fresh iterator over the elements. - /// - /// # Examples - /// - /// ```no_run - /// # use crabstep::TypedStreamDeserializer; - /// # let bytes: &[u8] = &[]; - /// # let mut typedstream = TypedStreamDeserializer::new(bytes); - /// # let root = typedstream.oxidize().unwrap(); - /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); - /// # let array = property.as_array().unwrap(); - /// for element in array.iter() { - /// println!("{:?}", element.as_string()); - /// } - /// ``` - #[must_use] - pub fn iter(&self) -> FoundationArrayIter<'a, 'b> { - FoundationArrayIter { - inner: self.elements.clone(), - } - } - - /// The element at `index` (a linear `O(index)` walk). - /// - /// # Examples - /// - /// ```no_run - /// # use crabstep::TypedStreamDeserializer; - /// # let bytes: &[u8] = &[]; - /// # let mut typedstream = TypedStreamDeserializer::new(bytes); - /// # let root = typedstream.oxidize().unwrap(); - /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); - /// # let array = property.as_array().unwrap(); - /// println!("{:?}", array.get(2).and_then(|element| element.as_i64())); - /// ``` - #[must_use] - pub fn get(&self, index: usize) -> Option> { - self.iter().nth(index) - } - - /// The first element. - #[must_use] - pub fn first(&self) -> Option> { - self.iter().next() - } -} - -impl<'a, 'b: 'a> IntoIterator for FoundationArray<'a, 'b> { - type Item = Property<'a, 'b>; - type IntoIter = FoundationArrayIter<'a, 'b>; - - fn into_iter(self) -> Self::IntoIter { - FoundationArrayIter { - inner: self.elements, - } - } -} - -impl<'a, 'b: 'a> IntoIterator for &FoundationArray<'a, 'b> { - type Item = Property<'a, 'b>; - type IntoIter = FoundationArrayIter<'a, 'b>; - - fn into_iter(self) -> Self::IntoIter { - self.iter() - } -} - -/// The iterator yielded by [`FoundationArray::iter`] and its [`IntoIterator`] impl. -#[derive(Debug, Clone)] -pub struct FoundationArrayIter<'a, 'b> { - inner: PropertyIterator<'a, 'b>, -} - -impl<'a, 'b: 'a> Iterator for FoundationArrayIter<'a, 'b> { - type Item = Property<'a, 'b>; - - fn next(&mut self) -> Option { - self.inner.next() - } -} - -/// A lazy view over the `(key, value)` pairs of an `NSDictionary` / -/// `NSMutableDictionary`, produced by [`Property::as_dictionary`]. Cheap to clone -/// and queryable any number of times; each key and value is a group-level -/// [`Property`]. -#[derive(Debug, Clone)] -pub struct FoundationDict<'a, 'b> { - entries: PropertyIterator<'a, 'b>, - len: usize, -} - -impl<'a, 'b: 'a> FoundationDict<'a, 'b> { - /// The number of entries (from the archived count). - #[must_use] - pub fn len(&self) -> usize { - self.len - } - - /// Whether the dictionary has no entries. - #[must_use] - pub fn is_empty(&self) -> bool { - self.len == 0 - } - - /// A fresh iterator over the `(key, value)` pairs. - #[must_use] - pub fn iter(&self) -> FoundationDictIter<'a, 'b> { - FoundationDictIter { - inner: self.entries.clone(), - } - } - - /// The value for a string `key`, or `None` if absent. - /// - /// This is a linear scan (`O(n)`); dictionaries archived in a `typedstream` - /// are typically small. Only string keys are matched — for other key types, - /// or to build an index for many lookups, use [`iter`](Self::iter). - /// - /// # Examples - /// - /// ```no_run - /// # use crabstep::TypedStreamDeserializer; - /// # let bytes: &[u8] = &[]; - /// # let mut typedstream = TypedStreamDeserializer::new(bytes); - /// # let root = typedstream.oxidize().unwrap(); - /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); - /// # let dict = property.as_dictionary().unwrap(); - /// if let Some(value) = dict.get("__kIMMessagePartAttributeName") { - /// println!("{:?}", value.as_i64()); - /// } - /// ``` - #[must_use] - pub fn get(&self, key: &str) -> Option> { - self.iter() - .find_map(|(k, v)| (k.as_string() == Some(key)).then_some(v)) - } - - /// Whether the dictionary contains the given string `key`. - /// - /// # Examples - /// - /// ```no_run - /// # use crabstep::TypedStreamDeserializer; - /// # let bytes: &[u8] = &[]; - /// # let mut typedstream = TypedStreamDeserializer::new(bytes); - /// # let root = typedstream.oxidize().unwrap(); - /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); - /// # let dict = property.as_dictionary().unwrap(); - /// if dict.contains_key("__kIMMessagePartAttributeName") { - /// // the message-part attribute is present - /// } - /// ``` - #[must_use] - pub fn contains_key(&self, key: &str) -> bool { - self.get(key).is_some() - } - - /// A fresh iterator over the keys. - /// - /// # Examples - /// - /// ```no_run - /// # use crabstep::TypedStreamDeserializer; - /// # let bytes: &[u8] = &[]; - /// # let mut typedstream = TypedStreamDeserializer::new(bytes); - /// # let root = typedstream.oxidize().unwrap(); - /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); - /// # let dict = property.as_dictionary().unwrap(); - /// for key in dict.keys() { - /// println!("{:?}", key.as_string()); - /// } - /// ``` - pub fn keys(&self) -> impl Iterator> { - self.iter().map(|(k, _)| k) - } - - /// A fresh iterator over the values. - /// - /// # Examples - /// - /// ```no_run - /// # use crabstep::TypedStreamDeserializer; - /// # let bytes: &[u8] = &[]; - /// # let mut typedstream = TypedStreamDeserializer::new(bytes); - /// # let root = typedstream.oxidize().unwrap(); - /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); - /// # let dict = property.as_dictionary().unwrap(); - /// for value in dict.values() { - /// println!("{:?}", value.as_i64()); - /// } - /// ``` - pub fn values(&self) -> impl Iterator> { - self.iter().map(|(_, v)| v) - } -} - -impl<'a, 'b: 'a> IntoIterator for FoundationDict<'a, 'b> { - type Item = (Property<'a, 'b>, Property<'a, 'b>); - type IntoIter = FoundationDictIter<'a, 'b>; - - fn into_iter(self) -> Self::IntoIter { - FoundationDictIter { - inner: self.entries, - } - } -} - -impl<'a, 'b: 'a> IntoIterator for &FoundationDict<'a, 'b> { - type Item = (Property<'a, 'b>, Property<'a, 'b>); - type IntoIter = FoundationDictIter<'a, 'b>; - - fn into_iter(self) -> Self::IntoIter { - self.iter() - } -} - -/// The iterator yielded by [`FoundationDict::iter`] and its [`IntoIterator`] impl. -#[derive(Debug, Clone)] -pub struct FoundationDictIter<'a, 'b> { - inner: PropertyIterator<'a, 'b>, -} - -impl<'a, 'b: 'a> Iterator for FoundationDictIter<'a, 'b> { - type Item = (Property<'a, 'b>, Property<'a, 'b>); - - fn next(&mut self) -> Option { - let key = self.inner.next()?; - // A missing value means an unpaired trailing key (malformed data); drop it. - let value = self.inner.next()?; - Some((key, value)) - } -} - -// MARK: Tests -#[cfg(test)] -mod tests { - extern crate std; - - use alloc::{vec, vec::Vec}; - use std::{env::current_dir, fs::File, io::Read}; - - use crate::deserializer::iter::Property; - use crate::deserializer::typedstream::TypedStreamDeserializer; - - /// Load a fixture by path relative to `src/test_data`. - fn load(rel: &str) -> Vec { - let path = current_dir().unwrap().join("src/test_data").join(rel); - let mut file = - File::open(&path).unwrap_or_else(|e| panic!("opening fixture {path:?}: {e}")); - let mut bytes = vec![]; - file.read_to_end(&mut bytes).unwrap(); - bytes - } - - // MARK: class_name - - #[test] - fn class_name_resolves_element_classes() { - // Iterating an `NSArray` yields one group per element; each element group's - // `class_name()` is the element's class, and the bare count group is None. - let bytes = load("foundation/NSArray"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let names: Vec<&str> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.class_name()) - .collect(); - - assert!(names.contains(&"NSString"), "names: {names:?}"); - assert!(names.contains(&"NSNumber"), "names: {names:?}"); - } - - #[test] - fn class_name_on_direct_object() { - // `class_name()` also works when called on a [`Property::Object`] directly - // (e.g. after stepping into a group with `iter().next()`). - let bytes = load("foundation/NSArray"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let object_names: Vec<&str> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| match group { - Property::Group(g) => g.first(), - _ => None, - }) - .filter_map(|inner| inner.class_name()) - .collect(); - - assert!( - object_names.contains(&"NSString"), - "names: {object_names:?}" - ); - } - - #[test] - fn class_name_is_none_for_primitive() { - // The `NSArray`'s count group is a bare primitive, no class. - let bytes = load("foundation/NSArray"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let has_primitive_group = ts - .resolve_properties(root) - .unwrap() - .any(|group| group.class_name().is_none()); - - assert!(has_primitive_group); - } - - // MARK: as_string - - #[test] - fn as_string_reads_both_string_variants() { - // NSArray([NSString "imm", NSMutableString "mut"]) - let bytes = load("foundation/NestedStrings"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let strings: Vec<&str> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_string()) - .collect(); - - assert_eq!(strings.len(), 2, "{strings:?}"); - assert!(strings.contains(&"imm"), "{strings:?}"); - assert!(strings.contains(&"mut"), "{strings:?}"); - } - - #[test] - fn as_string_reads_attributed_backing_text() { - // NSArray([NSAttributedString "styled"]) — exercises the scan path. - let bytes = load("foundation/NestedAttributed"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let strings: Vec<&str> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_string()) - .collect(); - - assert_eq!(strings, vec!["styled"]); - } - - #[test] - fn as_string_reads_real_mutable_attributed_body() { - // Real Messages NSMutableAttributedString: backing store is an - // NSMutableString reached as one of the root's groups (plain path). - let bytes = load("AttributedBodyTextOnly"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let strings: Vec<&str> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_string()) - .collect(); - - assert!(strings.contains(&"Noter test"), "{strings:?}"); - } - - // MARK: as_data - - #[test] - fn as_data_reads_both_data_variants() { - // NSArray([NSData [1,2], NSMutableData [3,4,5]]) - let bytes = load("foundation/NestedData"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let datas: Vec<&[u8]> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_data()) - .collect(); - - assert_eq!(datas.len(), 2, "{datas:?}"); - assert!(datas.contains(&&[0x01, 0x02][..]), "{datas:?}"); - assert!(datas.contains(&&[0x03, 0x04, 0x05][..]), "{datas:?}"); - } - - // MARK: numbers - - #[test] - fn as_f64_reads_decimal_double() { - // NumberDouble root: bare-primitive path, exercises the B1 DECIMAL fix. - let bytes = load("foundation/NumberDouble"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert_eq!(group.as_f64(), Some(100.5)); - } - - #[test] - fn as_f64_reads_float() { - let bytes = load("foundation/NumberFloat"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert_eq!(group.as_f64(), Some(3.5)); - } - - #[test] - fn as_i64_reads_large_negative() { - // NumberInt64 = -9_000_000_000: bare-primitive path, exercises the B2 fix. - let bytes = load("foundation/NumberInt64"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert_eq!(group.as_i64(), Some(-9_000_000_000)); - } - - #[test] - fn integer_coercion_and_strictness() { - // NumberInt = SignedInteger(42). - let bytes = load("foundation/NumberInt"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert_eq!(group.as_i64(), Some(42)); - assert_eq!(group.as_u64(), Some(42)); // non-negative signed coerces to u64 - assert_eq!(group.as_f64(), None); // no silent int -> float - assert_eq!(group.as_string(), None); // a number is not a string - assert_eq!(group.as_data(), None); - } - - #[test] - fn unsigned_coercion_rejects_negative() { - let bytes = load("foundation/NumberInt64"); // -9_000_000_000 - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert_eq!(group.as_u64(), None); - } - - // MARK: as_bool - #[test] - fn as_bool_reads_boolean() { - let bytes = load("foundation/NumberBool"); // NSNumber(true) -> SignedInteger(1) - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert_eq!(group.as_bool(), Some(true)); - assert_eq!(group.as_i64(), Some(1)); - } - - // MARK: wrappers - - #[test] - fn unwraps_nsnumber_object_and_reads_dict_entries() { - // NSDictionaryNested = { "arr": [..], "data": , "num": NSNumber(7) }. - // Keys are NSString objects; the NSNumber value exercises the unwrap path; - // the NSData value exercises as_data on a dictionary value. - let bytes = load("foundation/NSDictionaryNested"); - - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let keys: Vec<&str> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_string()) - .collect(); - assert!( - keys.contains(&"arr") && keys.contains(&"data") && keys.contains(&"num"), - "{keys:?}" - ); - - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let ints: Vec = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_i64()) - .collect(); - assert!(ints.contains(&7), "{ints:?}"); // NSNumber(7) value, unwrapped - - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let datas: Vec<&[u8]> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_data()) - .collect(); - assert!(datas.contains(&&[0x01, 0x02][..]), "{datas:?}"); // NSData value - } - - // MARK: as_array / as_set - - #[test] - fn as_array_yields_elements_both_variants_and_empty() { - // NestedContainers root holds NSArray[1,2], NSMutableArray[3], an empty - // NSArray, then non-array elements (dicts/sets) which as_array ignores. - let bytes = load("foundation/NestedContainers"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let arrays: Vec> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_array()) - .map(|array| array.into_iter().filter_map(|el| el.as_i64()).collect()) - .collect(); - - assert_eq!(arrays, vec![vec![1, 2], vec![3], vec![]]); - } - - #[test] - fn as_set_yields_members_both_variants() { - let bytes = load("foundation/NestedContainers"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let sets: Vec> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_set()) - .map(|set| { - set.into_iter() - .filter_map(|member| member.as_string()) - .collect() - }) - .collect(); - - assert_eq!(sets, vec![vec!["s"], vec!["ms"]]); - } - - // MARK: as_dictionary - - #[test] - fn as_dictionary_yields_pairs_both_variants() { - // NestedContainers holds NSDictionary{k:9} and NSMutableDictionary{mk:8}. - let bytes = load("foundation/NestedContainers"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let entries: Vec<(&str, i64)> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_dictionary()) - .flat_map(|dict| { - dict.into_iter() - .filter_map(|(key, value)| Some((key.as_string()?, value.as_i64()?))) - .collect::>() - }) - .collect(); - - assert_eq!(entries.len(), 2, "{entries:?}"); - assert!(entries.contains(&("k", 9)), "{entries:?}"); - assert!(entries.contains(&("mk", 8)), "{entries:?}"); - } - - #[test] - fn nested_array_inside_array() { - // NSArrayNested = [NSString "top", NSArray[1, 2]] — as_array on the nested - // element resolves the inner array. - let bytes = load("foundation/NSArrayNested"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let inner: Vec> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_array()) - .map(|array| array.into_iter().filter_map(|el| el.as_i64()).collect()) - .collect(); - - assert_eq!(inner, vec![vec![1, 2]]); - } - - // MARK: container negatives - - #[test] - fn container_accessors_reject_non_containers() { - let bytes = load("foundation/NumberInt"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert!(group.as_array().is_none()); - assert!(group.as_set().is_none()); - assert!(group.as_dictionary().is_none()); - } - - // MARK: as_date / as_unix_time - - #[test] - fn as_date_and_unix_time() { - // NestedScalars holds NSDate(timeIntervalSinceReferenceDate: 21692800). - let bytes = load("foundation/NestedScalars"); - - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let dates: Vec = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_date()) - .collect(); - assert_eq!(dates, vec![21692800.0]); - - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let unix: Vec = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_unix_time()) - .collect(); - assert_eq!(unix, vec![1_000_000_000.0]); - } - - // MARK: as_url - - #[test] - fn as_url_absolute_and_relative() { - // NestedScalars holds an absolute NSURL then a relative one; the relative - // one yields its relative component. - let bytes = load("foundation/NestedScalars"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let urls: Vec<&str> = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_url()) - .collect(); - - assert_eq!(urls, vec!["https://example.com/path?q=1", "page.html"]); - } - - // MARK: is_null - - #[test] - fn is_null_detects_nsnull() { - let bytes = load("foundation/NestedScalars"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let null_count = ts - .resolve_properties(root) - .unwrap() - .filter(|group| group.is_null()) - .count(); - - assert_eq!(null_count, 1); - } - - #[test] - fn scalar_accessors_reject_wrong_types() { - let bytes = load("foundation/NumberInt"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let group = ts.resolve_properties(root).unwrap().next().unwrap(); - - assert!(!group.is_null()); - assert_eq!(group.as_date(), None); - assert_eq!(group.as_unix_time(), None); - assert_eq!(group.as_url(), None); - } - - // MARK: container views - - #[test] - fn array_view_len_get_first() { - // First array element of NestedContainers is NSArray[1, 2]. - let bytes = load("foundation/NestedContainers"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let array = ts - .resolve_properties(root) - .unwrap() - .find_map(|group| group.as_array()) - .unwrap(); - - assert_eq!(array.len(), 2); - assert!(!array.is_empty()); - assert_eq!(array.first().and_then(|e| e.as_i64()), Some(1)); - assert_eq!(array.get(0).and_then(|e| e.as_i64()), Some(1)); - assert_eq!(array.get(1).and_then(|e| e.as_i64()), Some(2)); - assert!(array.get(2).is_none()); - } - - #[test] - fn array_view_empty() { - // NestedContainers also holds an empty NSArray. - let bytes = load("foundation/NestedContainers"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let empty = ts - .resolve_properties(root) - .unwrap() - .filter_map(|group| group.as_array()) - .find(|array| array.is_empty()) - .unwrap(); - - assert_eq!(empty.len(), 0); - assert!(empty.first().is_none()); - assert_eq!(empty.iter().count(), 0); - } - - #[test] - fn dict_view_get_contains_keys_values() { - // First dictionary of NestedContainers is NSDictionary { "k": 9 }. - let bytes = load("foundation/NestedContainers"); - let mut ts = TypedStreamDeserializer::new(&bytes); - let root = ts.oxidize().unwrap(); - let dict = ts - .resolve_properties(root) - .unwrap() - .find_map(|group| group.as_dictionary()) - .unwrap(); - - assert_eq!(dict.len(), 1); - assert!(!dict.is_empty()); - assert_eq!(dict.get("k").and_then(|v| v.as_i64()), Some(9)); - assert!(dict.get("missing").is_none()); - assert!(dict.contains_key("k")); - assert!(!dict.contains_key("missing")); - - let keys: Vec<&str> = dict.keys().filter_map(|k| k.as_string()).collect(); - assert_eq!(keys, vec!["k"]); - let values: Vec = dict.values().filter_map(|v| v.as_i64()).collect(); - assert_eq!(values, vec![9]); - } -} diff --git a/src/deserializer/foundation/array.rs b/src/deserializer/foundation/array.rs new file mode 100644 index 0000000..912c4d9 --- /dev/null +++ b/src/deserializer/foundation/array.rs @@ -0,0 +1,278 @@ +//! `as_array` / `as_set` and the [`FoundationArray`] view. + +use crate::deserializer::foundation::helpers::split_count; +use crate::deserializer::foundation::names::{ARRAY_CLASSES, SET_CLASSES}; +use crate::deserializer::iter::{Property, PropertyIterator}; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// The elements of an `NSArray` / `NSMutableArray` as a lazy [`FoundationArray`] + /// view (the leading element-count group is skipped). Supports `len` / + /// `get(index)` / `iter`; each element is a group-level [`Property`] on which + /// the other accessors (`as_string`, `as_i64`, a nested `as_array`, …) apply. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let bytes: &[u8] = &[]; // a typedstream payload + /// let mut typedstream = TypedStreamDeserializer::new(bytes); + /// let root = typedstream.oxidize().unwrap(); + /// + /// for property in typedstream.resolve_properties(root).unwrap() { + /// if let Some(array) = property.as_array() { + /// println!("{} elements", array.len()); + /// for element in &array { + /// println!("{:?}", element.as_string()); + /// } + /// } + /// } + /// ``` + #[must_use] + pub fn as_array(&self) -> Option> { + let (elements, len) = split_count(self.object_in_classes(ARRAY_CLASSES)?)?; + Some(FoundationArray { elements, len }) + } + + /// The members of an `NSSet` / `NSMutableSet` as a lazy [`FoundationArray`] + /// view (unordered). Shares the type with [`as_array`](Self::as_array): the + /// count group is skipped and each member is a group-level [`Property`]. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let bytes: &[u8] = &[]; + /// let mut typedstream = TypedStreamDeserializer::new(bytes); + /// let root = typedstream.oxidize().unwrap(); + /// + /// for property in typedstream.resolve_properties(root).unwrap() { + /// if let Some(set) = property.as_set() { + /// for member in &set { + /// println!("{:?}", member.as_string()); + /// } + /// } + /// } + /// ``` + #[must_use] + pub fn as_set(&self) -> Option> { + let (elements, len) = split_count(self.object_in_classes(SET_CLASSES)?)?; + Some(FoundationArray { elements, len }) + } +} + +/// A lazy view over the elements of an `NSArray` / `NSMutableArray` (or the +/// members of an `NSSet` / `NSMutableSet`), produced by [`Property::as_array`] / +/// [`Property::as_set`]. Cheap to clone and queryable any number of times; each +/// element is a group-level [`Property`], so the other accessors apply directly. +#[derive(Debug, Clone)] +pub struct FoundationArray<'a, 'b> { + elements: PropertyIterator<'a, 'b>, + len: usize, +} + +impl<'a, 'b: 'a> FoundationArray<'a, 'b> { + /// The number of elements (from the archived count). + #[must_use] + pub fn len(&self) -> usize { + self.len + } + + /// Whether the collection has no elements. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// A fresh iterator over the elements. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let array = property.as_array().unwrap(); + /// for element in array.iter() { + /// println!("{:?}", element.as_string()); + /// } + /// ``` + #[must_use] + pub fn iter(&self) -> FoundationArrayIter<'a, 'b> { + FoundationArrayIter { + inner: self.elements.clone(), + } + } + + /// The element at `index` (a linear `O(index)` walk). + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let array = property.as_array().unwrap(); + /// println!("{:?}", array.get(2).and_then(|element| element.as_i64())); + /// ``` + #[must_use] + pub fn get(&self, index: usize) -> Option> { + self.iter().nth(index) + } + + /// The first element. + #[must_use] + pub fn first(&self) -> Option> { + self.iter().next() + } +} + +impl<'a, 'b: 'a> IntoIterator for FoundationArray<'a, 'b> { + type Item = Property<'a, 'b>; + type IntoIter = FoundationArrayIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + FoundationArrayIter { + inner: self.elements, + } + } +} + +impl<'a, 'b: 'a> IntoIterator for &FoundationArray<'a, 'b> { + type Item = Property<'a, 'b>; + type IntoIter = FoundationArrayIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// The iterator yielded by [`FoundationArray::iter`] and its [`IntoIterator`] impl. +#[derive(Debug, Clone)] +pub struct FoundationArrayIter<'a, 'b> { + inner: PropertyIterator<'a, 'b>, +} + +impl<'a, 'b: 'a> Iterator for FoundationArrayIter<'a, 'b> { + type Item = Property<'a, 'b>; + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_array_yields_elements_both_variants_and_empty() { + // NestedContainers root holds NSArray[1,2], NSMutableArray[3], an empty + // NSArray, then non-array elements (dicts/sets) which as_array ignores. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let arrays: Vec> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_array()) + .map(|array| array.into_iter().filter_map(|el| el.as_i64()).collect()) + .collect(); + + assert_eq!(arrays, vec![vec![1, 2], vec![3], vec![]]); + } + + #[test] + fn as_set_yields_members_both_variants() { + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let sets: Vec> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_set()) + .map(|set| { + set.into_iter() + .filter_map(|member| member.as_string()) + .collect() + }) + .collect(); + + assert_eq!(sets, vec![vec!["s"], vec!["ms"]]); + } + + #[test] + fn nested_array_inside_array() { + // NSArrayNested = [NSString "top", NSArray[1, 2]] — as_array on the nested + // element resolves the inner array. + let bytes = load("foundation/NSArrayNested"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let inner: Vec> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_array()) + .map(|array| array.into_iter().filter_map(|el| el.as_i64()).collect()) + .collect(); + + assert_eq!(inner, vec![vec![1, 2]]); + } + + #[test] + fn container_accessors_reject_non_containers() { + let bytes = load("foundation/NumberInt"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert!(group.as_array().is_none()); + assert!(group.as_set().is_none()); + assert!(group.as_dictionary().is_none()); + } + + #[test] + fn array_view_len_get_first() { + // First array element of NestedContainers is NSArray[1, 2]. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let array = ts + .resolve_properties(root) + .unwrap() + .find_map(|group| group.as_array()) + .unwrap(); + + assert_eq!(array.len(), 2); + assert!(!array.is_empty()); + assert_eq!(array.first().and_then(|e| e.as_i64()), Some(1)); + assert_eq!(array.get(0).and_then(|e| e.as_i64()), Some(1)); + assert_eq!(array.get(1).and_then(|e| e.as_i64()), Some(2)); + assert!(array.get(2).is_none()); + } + + #[test] + fn array_view_empty() { + // NestedContainers also holds an empty NSArray. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let empty = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_array()) + .find(|array| array.is_empty()) + .unwrap(); + + assert_eq!(empty.len(), 0); + assert!(empty.first().is_none()); + assert_eq!(empty.iter().count(), 0); + } +} diff --git a/src/deserializer/foundation/boolean.rs b/src/deserializer/foundation/boolean.rs new file mode 100644 index 0000000..383098e --- /dev/null +++ b/src/deserializer/foundation/boolean.rs @@ -0,0 +1,33 @@ +//! `as_bool`: a boolean `NSNumber` (or bare primitive). + +use crate::deserializer::iter::Property; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// An `NSNumber` (or bare primitive) interpreted as a boolean. Returns `None` + /// for integer values other than `0` and `1`. + #[must_use] + pub fn as_bool(&self) -> Option { + match self.as_i64()? { + 0 => Some(false), + 1 => Some(true), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_bool_reads_boolean() { + let bytes = load("foundation/NumberBool"); // NSNumber(true) -> SignedInteger(1) + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_bool(), Some(true)); + assert_eq!(group.as_i64(), Some(1)); + } +} diff --git a/src/deserializer/foundation/bytes.rs b/src/deserializer/foundation/bytes.rs new file mode 100644 index 0000000..3081b00 --- /dev/null +++ b/src/deserializer/foundation/bytes.rs @@ -0,0 +1,51 @@ +//! `as_data`: the data cluster. + +use crate::deserializer::foundation::names::DATA_CLASSES; +use crate::deserializer::iter::Property; +use crate::models::output_data::OutputData; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// The raw bytes of an `NSData` / `NSMutableData`. + /// + /// crabstep does not interpret the bytes — they may be a `bplist00`, a + /// compressed blob, an image, etc. The caller decides what they are. + #[must_use] + pub fn as_data(&self) -> Option<&'a [u8]> { + let data = self.object_in_classes(DATA_CLASSES)?; + for prop in data { + if let Property::Group(group) = prop { + for child in group { + if let Property::Primitive(OutputData::Array(bytes)) = child { + return Some(bytes); + } + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_data_reads_both_data_variants() { + // NSArray([NSData [1,2], NSMutableData [3,4,5]]) + let bytes = load("foundation/NestedData"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let datas: Vec<&[u8]> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_data()) + .collect(); + + assert_eq!(datas.len(), 2, "{datas:?}"); + assert!(datas.contains(&&[0x01, 0x02][..]), "{datas:?}"); + assert!(datas.contains(&&[0x03, 0x04, 0x05][..]), "{datas:?}"); + } +} diff --git a/src/deserializer/foundation/class.rs b/src/deserializer/foundation/class.rs new file mode 100644 index 0000000..34e9956 --- /dev/null +++ b/src/deserializer/foundation/class.rs @@ -0,0 +1,87 @@ +//! The `class_name` escape hatch. + +use crate::deserializer::iter::Property; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// The Objective-C class name of the object this property refers to, if any. + /// + /// Resolves whether `self` is a [`Property::Object`] directly or a + /// [`Property::Group`] whose first element is an object. This is the escape + /// hatch for classes the `foundation` feature does not model: the class name + /// plus the raw subtree (via [`Property::Object`]) are always available, so a + /// consumer can handle the long tail of app-specific classes itself. + #[must_use] + pub fn class_name(&self) -> Option<&'a str> { + match self { + Property::Object { name, .. } => Some(*name), + Property::Group(group) => match group.first()? { + Property::Object { name, .. } => Some(name), + _ => None, + }, + Property::Primitive(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::iter::Property; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn class_name_resolves_element_classes() { + // Iterating an `NSArray` yields one group per element; each element group's + // `class_name()` is the element's class, and the bare count group is None. + let bytes = load("foundation/NSArray"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let names: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.class_name()) + .collect(); + + assert!(names.contains(&"NSString"), "names: {names:?}"); + assert!(names.contains(&"NSNumber"), "names: {names:?}"); + } + + #[test] + fn class_name_on_direct_object() { + // `class_name()` also works when called on a [`Property::Object`] directly + // (e.g. after stepping into a group with `iter().next()`). + let bytes = load("foundation/NSArray"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let object_names: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| match group { + Property::Group(g) => g.first(), + _ => None, + }) + .filter_map(|inner| inner.class_name()) + .collect(); + + assert!( + object_names.contains(&"NSString"), + "names: {object_names:?}" + ); + } + + #[test] + fn class_name_is_none_for_primitive() { + // The `NSArray`'s count group is a bare primitive, no class. + let bytes = load("foundation/NSArray"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let has_primitive_group = ts + .resolve_properties(root) + .unwrap() + .any(|group| group.class_name().is_none()); + + assert!(has_primitive_group); + } +} diff --git a/src/deserializer/foundation/date.rs b/src/deserializer/foundation/date.rs new file mode 100644 index 0000000..d6f565d --- /dev/null +++ b/src/deserializer/foundation/date.rs @@ -0,0 +1,74 @@ +//! `as_date` / `as_unix_time`: `NSDate`. + +use crate::deserializer::iter::Property; +use crate::models::output_data::OutputData; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// An `NSDate` as seconds since the Cocoa reference epoch (2001-01-01 00:00:00 + /// UTC). Use [`as_unix_time`](Self::as_unix_time) for seconds since the Unix + /// epoch. + #[must_use] + pub fn as_date(&self) -> Option { + let mut data = self.object_in_classes(&["NSDate"])?; + match data.next()? { + Property::Group(group) => match group.first()? { + Property::Primitive(OutputData::Double(seconds)) => Some(*seconds), + _ => None, + }, + _ => None, + } + } + + /// An `NSDate` as seconds since the Unix epoch (1970-01-01 00:00:00 UTC), + /// i.e. [`as_date`](Self::as_date)` + 978_307_200.0` (the offset between the + /// Unix and Cocoa reference epochs). + #[must_use] + pub fn as_unix_time(&self) -> Option { + self.as_date().map(|seconds| seconds + 978_307_200.0) + } +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_date_and_unix_time() { + // NestedScalars holds NSDate(timeIntervalSinceReferenceDate: 21692800). + let bytes = load("foundation/NestedScalars"); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let dates: Vec = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_date()) + .collect(); + assert_eq!(dates, vec![21692800.0]); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let unix: Vec = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_unix_time()) + .collect(); + assert_eq!(unix, vec![1_000_000_000.0]); + } + + #[test] + fn scalar_accessors_reject_wrong_types() { + let bytes = load("foundation/NumberInt"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert!(!group.is_null()); + assert_eq!(group.as_date(), None); + assert_eq!(group.as_unix_time(), None); + assert_eq!(group.as_url(), None); + } +} diff --git a/src/deserializer/foundation/dict.rs b/src/deserializer/foundation/dict.rs new file mode 100644 index 0000000..09f658a --- /dev/null +++ b/src/deserializer/foundation/dict.rs @@ -0,0 +1,247 @@ +//! `as_dictionary` and the [`FoundationDict`] view. + +use crate::deserializer::foundation::helpers::split_count; +use crate::deserializer::foundation::names::DICT_CLASSES; +use crate::deserializer::iter::{Property, PropertyIterator}; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// The entries of an `NSDictionary` / `NSMutableDictionary` as a lazy + /// [`FoundationDict`] view (the leading count group is skipped). Look up a + /// string key with [`get`](FoundationDict::get), or iterate the `(key, value)` + /// pairs; each key and value is a group-level [`Property`]. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let bytes: &[u8] = &[]; + /// let mut typedstream = TypedStreamDeserializer::new(bytes); + /// let root = typedstream.oxidize().unwrap(); + /// + /// for property in typedstream.resolve_properties(root).unwrap() { + /// if let Some(dict) = property.as_dictionary() { + /// // Look up a value by its string key. + /// if let Some(part) = dict.get("__kIMMessagePartAttributeName") { + /// println!("part index = {:?}", part.as_i64()); + /// } + /// // Or iterate every entry. + /// for (key, value) in &dict { + /// println!("{:?} => {:?}", key.as_string(), value.as_i64()); + /// } + /// } + /// } + /// ``` + #[must_use] + pub fn as_dictionary(&self) -> Option> { + let (entries, len) = split_count(self.object_in_classes(DICT_CLASSES)?)?; + Some(FoundationDict { entries, len }) + } +} + +/// A lazy view over the `(key, value)` pairs of an `NSDictionary` / +/// `NSMutableDictionary`, produced by [`Property::as_dictionary`]. Cheap to clone +/// and queryable any number of times; each key and value is a group-level +/// [`Property`]. +#[derive(Debug, Clone)] +pub struct FoundationDict<'a, 'b> { + entries: PropertyIterator<'a, 'b>, + len: usize, +} + +impl<'a, 'b: 'a> FoundationDict<'a, 'b> { + /// The number of entries (from the archived count). + #[must_use] + pub fn len(&self) -> usize { + self.len + } + + /// Whether the dictionary has no entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// A fresh iterator over the `(key, value)` pairs. + #[must_use] + pub fn iter(&self) -> FoundationDictIter<'a, 'b> { + FoundationDictIter { + inner: self.entries.clone(), + } + } + + /// The value for a string `key`, or `None` if absent. + /// + /// This is a linear scan (`O(n)`); dictionaries archived in a `typedstream` + /// are typically small. Only string keys are matched — for other key types, + /// or to build an index for many lookups, use [`iter`](Self::iter). + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// if let Some(value) = dict.get("__kIMMessagePartAttributeName") { + /// println!("{:?}", value.as_i64()); + /// } + /// ``` + #[must_use] + pub fn get(&self, key: &str) -> Option> { + self.iter() + .find_map(|(k, v)| (k.as_string() == Some(key)).then_some(v)) + } + + /// Whether the dictionary contains the given string `key`. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// if dict.contains_key("__kIMMessagePartAttributeName") { + /// // the message-part attribute is present + /// } + /// ``` + #[must_use] + pub fn contains_key(&self, key: &str) -> bool { + self.get(key).is_some() + } + + /// A fresh iterator over the keys. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// for key in dict.keys() { + /// println!("{:?}", key.as_string()); + /// } + /// ``` + pub fn keys(&self) -> impl Iterator> { + self.iter().map(|(k, _)| k) + } + + /// A fresh iterator over the values. + /// + /// # Examples + /// + /// ```no_run + /// # use crabstep::TypedStreamDeserializer; + /// # let bytes: &[u8] = &[]; + /// # let mut typedstream = TypedStreamDeserializer::new(bytes); + /// # let root = typedstream.oxidize().unwrap(); + /// # let property = typedstream.resolve_properties(root).unwrap().next().unwrap(); + /// # let dict = property.as_dictionary().unwrap(); + /// for value in dict.values() { + /// println!("{:?}", value.as_i64()); + /// } + /// ``` + pub fn values(&self) -> impl Iterator> { + self.iter().map(|(_, v)| v) + } +} + +impl<'a, 'b: 'a> IntoIterator for FoundationDict<'a, 'b> { + type Item = (Property<'a, 'b>, Property<'a, 'b>); + type IntoIter = FoundationDictIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + FoundationDictIter { + inner: self.entries, + } + } +} + +impl<'a, 'b: 'a> IntoIterator for &FoundationDict<'a, 'b> { + type Item = (Property<'a, 'b>, Property<'a, 'b>); + type IntoIter = FoundationDictIter<'a, 'b>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// The iterator yielded by [`FoundationDict::iter`] and its [`IntoIterator`] impl. +#[derive(Debug, Clone)] +pub struct FoundationDictIter<'a, 'b> { + inner: PropertyIterator<'a, 'b>, +} + +impl<'a, 'b: 'a> Iterator for FoundationDictIter<'a, 'b> { + type Item = (Property<'a, 'b>, Property<'a, 'b>); + + fn next(&mut self) -> Option { + let key = self.inner.next()?; + // A missing value means an unpaired trailing key (malformed data); drop it. + let value = self.inner.next()?; + Some((key, value)) + } +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_dictionary_yields_pairs_both_variants() { + // NestedContainers holds NSDictionary{k:9} and NSMutableDictionary{mk:8}. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let entries: Vec<(&str, i64)> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_dictionary()) + .flat_map(|dict| { + dict.into_iter() + .filter_map(|(key, value)| Some((key.as_string()?, value.as_i64()?))) + .collect::>() + }) + .collect(); + + assert_eq!(entries.len(), 2, "{entries:?}"); + assert!(entries.contains(&("k", 9)), "{entries:?}"); + assert!(entries.contains(&("mk", 8)), "{entries:?}"); + } + + #[test] + fn dict_view_get_contains_keys_values() { + // First dictionary of NestedContainers is NSDictionary { "k": 9 }. + let bytes = load("foundation/NestedContainers"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let dict = ts + .resolve_properties(root) + .unwrap() + .find_map(|group| group.as_dictionary()) + .unwrap(); + + assert_eq!(dict.len(), 1); + assert!(!dict.is_empty()); + assert_eq!(dict.get("k").and_then(|v| v.as_i64()), Some(9)); + assert!(dict.get("missing").is_none()); + assert!(dict.contains_key("k")); + assert!(!dict.contains_key("missing")); + + let keys: Vec<&str> = dict.keys().filter_map(|k| k.as_string()).collect(); + assert_eq!(keys, vec!["k"]); + let values: Vec = dict.values().filter_map(|v| v.as_i64()).collect(); + assert_eq!(values, vec![9]); + } +} diff --git a/src/deserializer/foundation/helpers.rs b/src/deserializer/foundation/helpers.rs new file mode 100644 index 0000000..0c3a0ce --- /dev/null +++ b/src/deserializer/foundation/helpers.rs @@ -0,0 +1,27 @@ +//! Helpers shared across the Foundation accessors. + +use crate::deserializer::iter::{Property, PropertyIterator}; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// If `self` is a group whose first item is an object whose class is in + /// `classes`, return that object's data iterator. + pub(crate) fn object_in_classes(&self, classes: &[&str]) -> Option> { + let Property::Group(group) = self else { + return None; + }; + match group.first()? { + Property::Object { name, data, .. } if classes.contains(&name) => Some(data), + _ => None, + } + } +} + +/// Read the leading count group of a container's data, returning the remaining +/// iterator (positioned at the first element/entry) and the declared count. +pub(crate) fn split_count<'a, 'b: 'a>( + mut data: PropertyIterator<'a, 'b>, +) -> Option<(PropertyIterator<'a, 'b>, usize)> { + let count = data.next()?; + let len = usize::try_from(count.as_i64().unwrap_or(0)).unwrap_or(0); + Some((data, len)) +} diff --git a/src/deserializer/foundation/mod.rs b/src/deserializer/foundation/mod.rs new file mode 100644 index 0000000..15bfcf5 --- /dev/null +++ b/src/deserializer/foundation/mod.rs @@ -0,0 +1,39 @@ +/*! +Typed accessors for common Apple [Foundation](https://developer.apple.com/documentation/foundation) classes. + +Enabled by the `foundation` cargo feature. These methods interpret the generic +[`Property`](crate::deserializer::iter::Property) tree as specific Foundation +types (`NSString`, `NSDictionary`, …) so consumers do not have to re-implement +class-name matching (and re-discover its footguns, e.g. that the data cluster +archives as both `NSData` and `NSMutableData`). + +This feature is purely for convenience: the parser and the +[`Property`](crate::deserializer::iter::Property) / +[`OutputData`](crate::models::output_data::OutputData) model are unchanged whether +or not it is enabled, and any class not modeled here stays reachable through +[`Property::Object`](crate::deserializer::iter::Property::Object), so nothing is +ever lost. + +Accessors are methods on a group-level +[`Property`](crate::deserializer::iter::Property): the value yielded while +iterating an object's properties. Each Foundation type lives in its own module. +*/ + +mod array; +mod boolean; +mod bytes; +mod class; +mod date; +mod dict; +mod helpers; +mod names; +mod null; +mod number; +mod string; +mod url; + +#[cfg(test)] +mod test_support; + +pub use crate::deserializer::foundation::array::{FoundationArray, FoundationArrayIter}; +pub use crate::deserializer::foundation::dict::{FoundationDict, FoundationDictIter}; diff --git a/src/deserializer/foundation/names.rs b/src/deserializer/foundation/names.rs new file mode 100644 index 0000000..ba6c636 --- /dev/null +++ b/src/deserializer/foundation/names.rs @@ -0,0 +1,19 @@ +//! Cluster class-name sets. +//! +//! Foundation archives several types as both an immutable and a mutable variant +//! (and the data cluster archives as both `NSData` and `NSMutableData`); matching +//! all variants in one place keeps the footgun centralized. + +/// Denotes string data +pub(crate) const STRING_CLASSES: &[&str] = &["NSString", "NSMutableString"]; +/// Denotes string data with attributes (e.g. font, color, …) +pub(crate) const ATTRIBUTED_STRING_CLASSES: &[&str] = + &["NSAttributedString", "NSMutableAttributedString"]; +/// Denotes raw bytes +pub(crate) const DATA_CLASSES: &[&str] = &["NSData", "NSMutableData"]; +/// Denotes ordered collections of arbitrary objects. +pub(crate) const ARRAY_CLASSES: &[&str] = &["NSArray", "NSMutableArray"]; +/// Denotes unordered collections of arbitrary objects. +pub(crate) const DICT_CLASSES: &[&str] = &["NSDictionary", "NSMutableDictionary"]; +/// Denotes unordered collections of unique arbitrary objects. +pub(crate) const SET_CLASSES: &[&str] = &["NSSet", "NSMutableSet"]; diff --git a/src/deserializer/foundation/null.rs b/src/deserializer/foundation/null.rs new file mode 100644 index 0000000..2d94226 --- /dev/null +++ b/src/deserializer/foundation/null.rs @@ -0,0 +1,42 @@ +//! `is_null`: `NSNull` and nil references. + +use crate::deserializer::iter::Property; +use crate::models::output_data::OutputData; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// Whether this property is an `NSNull` instance or a nil object reference + /// ([`OutputData::Null`]). + #[must_use] + pub fn is_null(&self) -> bool { + match self { + Property::Object { name: "NSNull", .. } | Property::Primitive(OutputData::Null) => true, + Property::Group(group) => matches!( + group.first(), + Some( + Property::Object { name: "NSNull", .. } | Property::Primitive(OutputData::Null) + ) + ), + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn is_null_detects_nsnull() { + let bytes = load("foundation/NestedScalars"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let null_count = ts + .resolve_properties(root) + .unwrap() + .filter(|group| group.is_null()) + .count(); + + assert_eq!(null_count, 1); + } +} diff --git a/src/deserializer/foundation/number.rs b/src/deserializer/foundation/number.rs new file mode 100644 index 0000000..f4b516e --- /dev/null +++ b/src/deserializer/foundation/number.rs @@ -0,0 +1,167 @@ +//! `as_i64` / `as_u64` / `as_f64`: numeric `NSNumber`s (or bare primitives). + +use crate::deserializer::iter::Property; +use crate::models::output_data::OutputData; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// An `NSNumber` (or bare primitive) interpreted as a signed integer. Accepts + /// either integer encoding; an unsigned value that does not fit `i64` yields + /// `None`. Float/double values are *not* coerced. + #[must_use] + pub fn as_i64(&self) -> Option { + match self.scalar()? { + OutputData::SignedInteger(v) => Some(*v), + OutputData::UnsignedInteger(v) => i64::try_from(*v).ok(), + _ => None, + } + } + + /// An `NSNumber` (or bare primitive) interpreted as an unsigned integer. + /// Accepts either integer encoding; a negative signed value yields `None`. + /// Float/double values are *not* coerced. + #[must_use] + pub fn as_u64(&self) -> Option { + match self.scalar()? { + OutputData::UnsignedInteger(v) => Some(*v), + OutputData::SignedInteger(v) => u64::try_from(*v).ok(), + _ => None, + } + } + + /// An `NSNumber` (or bare primitive) interpreted as a double. Accepts `Float` + /// and `Double`; integer values are *not* coerced. + #[must_use] + pub fn as_f64(&self) -> Option { + match self.scalar()? { + OutputData::Double(v) => Some(*v), + OutputData::Float(v) => Some(f64::from(*v)), + _ => None, + } + } + + /// The underlying scalar value of a group that is either a bare primitive or + /// an `NSNumber` wrapping one. + fn scalar(&self) -> Option<&'b OutputData<'a>> { + let Property::Group(group) = self else { + return None; + }; + match group.first()? { + Property::Primitive(value) => Some(value), + Property::Object { + name: "NSNumber", + mut data, + .. + } => match data.next()? { + Property::Group(inner) => match inner.first()? { + Property::Primitive(value) => Some(value), + _ => None, + }, + _ => None, + }, + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_f64_reads_decimal_double() { + // NumberDouble root: bare-primitive path, exercises the B1 DECIMAL fix. + let bytes = load("foundation/NumberDouble"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_f64(), Some(100.5)); + } + + #[test] + fn as_f64_reads_float() { + let bytes = load("foundation/NumberFloat"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_f64(), Some(3.5)); + } + + #[test] + fn as_i64_reads_large_negative() { + // NumberInt64 = -9_000_000_000: bare-primitive path, exercises the B2 fix. + let bytes = load("foundation/NumberInt64"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_i64(), Some(-9_000_000_000)); + } + + #[test] + fn integer_coercion_and_strictness() { + // NumberInt = SignedInteger(42). + let bytes = load("foundation/NumberInt"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_i64(), Some(42)); + assert_eq!(group.as_u64(), Some(42)); // non-negative signed coerces to u64 + assert_eq!(group.as_f64(), None); // no silent int -> float + assert_eq!(group.as_string(), None); // a number is not a string + assert_eq!(group.as_data(), None); + } + + #[test] + fn unsigned_coercion_rejects_negative() { + let bytes = load("foundation/NumberInt64"); // -9_000_000_000 + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let group = ts.resolve_properties(root).unwrap().next().unwrap(); + + assert_eq!(group.as_u64(), None); + } + + #[test] + fn unwraps_nsnumber_object_and_reads_dict_entries() { + // NSDictionaryNested = { "arr": [..], "data": , "num": NSNumber(7) }. + // Keys are NSString objects; the NSNumber value exercises the unwrap path; + // the NSData value exercises as_data on a dictionary value. + let bytes = load("foundation/NSDictionaryNested"); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let keys: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + assert!( + keys.contains(&"arr") && keys.contains(&"data") && keys.contains(&"num"), + "{keys:?}" + ); + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let ints: Vec = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_i64()) + .collect(); + assert!(ints.contains(&7), "{ints:?}"); // NSNumber(7) value, unwrapped + + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let datas: Vec<&[u8]> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_data()) + .collect(); + assert!(datas.contains(&&[0x01, 0x02][..]), "{datas:?}"); // NSData value + } +} diff --git a/src/deserializer/foundation/string.rs b/src/deserializer/foundation/string.rs new file mode 100644 index 0000000..467e38a --- /dev/null +++ b/src/deserializer/foundation/string.rs @@ -0,0 +1,100 @@ +//! `as_string`: the string cluster and attributed strings. + +use crate::deserializer::foundation::names::{ATTRIBUTED_STRING_CLASSES, STRING_CLASSES}; +use crate::deserializer::iter::{Property, PropertyIterator}; +use crate::models::output_data::OutputData; + +/// Extract the backing UTF-8 of a string-cluster object: the first `String` +/// primitive in the object's first data group. +fn backing_string<'a, 'b: 'a>(mut data: PropertyIterator<'a, 'b>) -> Option<&'a str> { + if let Property::Group(group) = data.next()? + && let Some(Property::Primitive(OutputData::String(s))) = group.first() + { + return Some(s); + } + None +} + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// The backing string of an `NSString` / `NSMutableString`, or the plain text + /// of an `NSAttributedString` / `NSMutableAttributedString` (its attributes + /// remain reachable through the generic [`Property`] tree). + #[must_use] + pub fn as_string(&self) -> Option<&'a str> { + if let Some(data) = self.object_in_classes(STRING_CLASSES) { + return backing_string(data); + } + // An attributed string stores its backing store as a nested + // `NSString`/`NSMutableString`; its position among the groups varies by + // producer (the attributes dictionary often comes first), so scan. + if let Some(data) = self.object_in_classes(ATTRIBUTED_STRING_CLASSES) { + for prop in data { + if let Property::Group(group) = prop + && let Some(Property::Object { + name, data: inner, .. + }) = group.first() + && STRING_CLASSES.contains(&name) + { + return backing_string(inner); + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_string_reads_both_string_variants() { + // NSArray([NSString "imm", NSMutableString "mut"]) + let bytes = load("foundation/NestedStrings"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let strings: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + + assert_eq!(strings.len(), 2, "{strings:?}"); + assert!(strings.contains(&"imm"), "{strings:?}"); + assert!(strings.contains(&"mut"), "{strings:?}"); + } + + #[test] + fn as_string_reads_attributed_backing_text() { + // NSArray([NSAttributedString "styled"]) — exercises the scan path. + let bytes = load("foundation/NestedAttributed"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let strings: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + + assert_eq!(strings, vec!["styled"]); + } + + #[test] + fn as_string_reads_real_mutable_attributed_body() { + // Real Messages NSMutableAttributedString: backing store is an + // NSMutableString reached as one of the root's groups (plain path). + let bytes = load("AttributedBodyTextOnly"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let strings: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_string()) + .collect(); + + assert!(strings.contains(&"Noter test"), "{strings:?}"); + } +} diff --git a/src/deserializer/foundation/test_support.rs b/src/deserializer/foundation/test_support.rs new file mode 100644 index 0000000..309ac19 --- /dev/null +++ b/src/deserializer/foundation/test_support.rs @@ -0,0 +1,15 @@ +//! Shared helpers for the Foundation accessor tests. + +extern crate std; + +use alloc::{vec, vec::Vec}; +use std::{env::current_dir, fs::File, io::Read}; + +/// Load a fixture by path relative to `src/test_data`. +pub(super) fn load(rel: &str) -> Vec { + let path = current_dir().unwrap().join("src/test_data").join(rel); + let mut file = File::open(&path).unwrap_or_else(|e| panic!("opening fixture {path:?}: {e}")); + let mut bytes = vec![]; + file.read_to_end(&mut bytes).unwrap(); + bytes +} diff --git a/src/deserializer/foundation/url.rs b/src/deserializer/foundation/url.rs new file mode 100644 index 0000000..2b858f8 --- /dev/null +++ b/src/deserializer/foundation/url.rs @@ -0,0 +1,43 @@ +//! `as_url`: `NSURL`. + +use crate::deserializer::iter::Property; + +impl<'a, 'b: 'a> Property<'a, 'b> { + /// The string of an `NSURL`. For an absolute URL this is the full URL; for a + /// URL created relative to a base, it is the relative component (the base + /// `NSURL` remains reachable through the generic [`Property`] tree). + #[must_use] + pub fn as_url(&self) -> Option<&'a str> { + let data = self.object_in_classes(&["NSURL"])?; + for group in data { + if let Some(url) = group.as_string() { + return Some(url); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use crate::deserializer::foundation::test_support::load; + use crate::deserializer::typedstream::TypedStreamDeserializer; + + #[test] + fn as_url_absolute_and_relative() { + // NestedScalars holds an absolute NSURL then a relative one; the relative + // one yields its relative component. + let bytes = load("foundation/NestedScalars"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let urls: Vec<&str> = ts + .resolve_properties(root) + .unwrap() + .filter_map(|group| group.as_url()) + .collect(); + + assert_eq!(urls, vec!["https://example.com/path?q=1", "page.html"]); + } +} From bdfa499ef311426e0b49f142f0c238386ca64955 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 16:54:23 -0700 Subject: [PATCH 14/17] Add build steps for foundation features in CI workflows --- .github/workflows/release.yml | 3 +++ .github/workflows/test.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61086be..7dc1537 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,9 @@ jobs: steps: - uses: actions/checkout@v4 - run: rustup update stable && rustup default stable + - run: cargo build --no-default-features + - run: cargo build --features std + - run: cargo build --features foundation - run: cargo test --verbose --all-features - run: | export VERSION=${{ github.event.release.tag_name }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83a4e69..e1830d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,5 +17,8 @@ jobs: steps: - uses: actions/checkout@v4 - run: rustup update stable && rustup default stable + - run: cargo build --no-default-features + - run: cargo build --features std + - run: cargo build --features foundation - run: cargo clippy --all-targets --all-features - run: cargo test --verbose --all-features From a9a874b2f2c791cab8aca41c31c6daaf0bbc0383 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 16:59:55 -0700 Subject: [PATCH 15/17] Update docs for Foundation accessors; improve error handling in deserializer --- README.md | 4 +- src/deserializer/foundation/bytes.rs | 2 +- src/deserializer/foundation/helpers.rs | 17 +++++--- src/deserializer/foundation/mod.rs | 9 +++-- src/deserializer/foundation/number.rs | 40 +++++++++++++------ src/deserializer/iter.rs | 49 +++++++++++++++-------- src/deserializer/typedstream.rs | 54 +++++++++++++++++++++++++- 7 files changed, 134 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 4dfb3cb..6015a42 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ The `typedstream` format is derived from the data structure used by `NeXTSTEP`'s `crabstep` is `no_std` by default and requires no dependencies. The following optional features are purely additive: -- `std` — enables `std`-only conveniences, such as `print_resolved` for debugging an object graph. -- `foundation` — adds typed accessors on `Property` for common Apple Foundation classes (`as_string`, `as_data`, `as_array`, `as_dictionary`, `as_date`, `as_url`, and more), so consumers do not have to hand-roll class-name matching. See the `deserializer::foundation` module. +- `std`: enables `std`-only conveniences, such as `print_resolved` for debugging an object graph. +- `foundation`: adds typed accessors on `Property` for common Apple [Foundation](https://developer.apple.com/documentation/foundation) classes (`as_string`, `as_data`, `as_array`, `as_dictionary`, `as_date`, `as_url`, and more), so consumers do not have to hand-roll class-name matching. See the `deserializer::foundation` module. Enable a feature in your `Cargo.toml`: diff --git a/src/deserializer/foundation/bytes.rs b/src/deserializer/foundation/bytes.rs index 3081b00..8198f3c 100644 --- a/src/deserializer/foundation/bytes.rs +++ b/src/deserializer/foundation/bytes.rs @@ -7,7 +7,7 @@ use crate::models::output_data::OutputData; impl<'a, 'b: 'a> Property<'a, 'b> { /// The raw bytes of an `NSData` / `NSMutableData`. /// - /// crabstep does not interpret the bytes — they may be a `bplist00`, a + /// crabstep does not interpret the bytes, as they may be a `bplist00`, a /// compressed blob, an image, etc. The caller decides what they are. #[must_use] pub fn as_data(&self) -> Option<&'a [u8]> { diff --git a/src/deserializer/foundation/helpers.rs b/src/deserializer/foundation/helpers.rs index 0c3a0ce..cc70baf 100644 --- a/src/deserializer/foundation/helpers.rs +++ b/src/deserializer/foundation/helpers.rs @@ -6,11 +6,14 @@ impl<'a, 'b: 'a> Property<'a, 'b> { /// If `self` is a group whose first item is an object whose class is in /// `classes`, return that object's data iterator. pub(crate) fn object_in_classes(&self, classes: &[&str]) -> Option> { - let Property::Group(group) = self else { - return None; - }; - match group.first()? { - Property::Object { name, data, .. } if classes.contains(&name) => Some(data), + match self { + // `self` is the object directly (e.g. from `root()`/`resolve_object`). + Property::Object { name, data, .. } if classes.contains(name) => Some(data.clone()), + // `self` is a group whose first item is the object (iteration). + Property::Group(group) => match group.first()? { + Property::Object { name, data, .. } if classes.contains(&name) => Some(data), + _ => None, + }, _ => None, } } @@ -22,6 +25,8 @@ pub(crate) fn split_count<'a, 'b: 'a>( mut data: PropertyIterator<'a, 'b>, ) -> Option<(PropertyIterator<'a, 'b>, usize)> { let count = data.next()?; - let len = usize::try_from(count.as_i64().unwrap_or(0)).unwrap_or(0); + // A non-integer or negative count means this isn't a well-formed container; + // signal that rather than reporting a bogus zero length. + let len = usize::try_from(count.as_i64()?).ok()?; Some((data, len)) } diff --git a/src/deserializer/foundation/mod.rs b/src/deserializer/foundation/mod.rs index 15bfcf5..028218f 100644 --- a/src/deserializer/foundation/mod.rs +++ b/src/deserializer/foundation/mod.rs @@ -14,9 +14,12 @@ or not it is enabled, and any class not modeled here stays reachable through [`Property::Object`](crate::deserializer::iter::Property::Object), so nothing is ever lost. -Accessors are methods on a group-level -[`Property`](crate::deserializer::iter::Property): the value yielded while -iterating an object's properties. Each Foundation type lives in its own module. +Accessors are methods on a [`Property`](crate::deserializer::iter::Property) — +either a group yielded while iterating an object's properties, or an object +obtained from [`TypedStreamDeserializer::root`](crate::TypedStreamDeserializer::root) +/ [`resolve_object`](crate::TypedStreamDeserializer::resolve_object) when the value +you want *is* the root (or a referenced) object. Each Foundation type lives in its +own module. */ mod array; diff --git a/src/deserializer/foundation/number.rs b/src/deserializer/foundation/number.rs index f4b516e..edc46b4 100644 --- a/src/deserializer/foundation/number.rs +++ b/src/deserializer/foundation/number.rs @@ -1,6 +1,6 @@ //! `as_i64` / `as_u64` / `as_f64`: numeric `NSNumber`s (or bare primitives). -use crate::deserializer::iter::Property; +use crate::deserializer::iter::{Property, PropertyIterator}; use crate::models::output_data::OutputData; impl<'a, 'b: 'a> Property<'a, 'b> { @@ -42,20 +42,20 @@ impl<'a, 'b: 'a> Property<'a, 'b> { /// The underlying scalar value of a group that is either a bare primitive or /// an `NSNumber` wrapping one. fn scalar(&self) -> Option<&'b OutputData<'a>> { - let Property::Group(group) = self else { - return None; - }; - match group.first()? { + match self { Property::Primitive(value) => Some(value), Property::Object { name: "NSNumber", - mut data, + data, .. - } => match data.next()? { - Property::Group(inner) => match inner.first()? { - Property::Primitive(value) => Some(value), - _ => None, - }, + } => number_value(data.clone()), + Property::Group(group) => match group.first()? { + Property::Primitive(value) => Some(value), + Property::Object { + name: "NSNumber", + data, + .. + } => number_value(data), _ => None, }, _ => None, @@ -63,6 +63,17 @@ impl<'a, 'b: 'a> Property<'a, 'b> { } } +/// The first primitive in an `NSNumber` object's first group. +fn number_value<'a, 'b: 'a>(mut data: PropertyIterator<'a, 'b>) -> Option<&'b OutputData<'a>> { + match data.next()? { + Property::Group(inner) => match inner.first()? { + Property::Primitive(value) => Some(value), + _ => None, + }, + _ => None, + } +} + #[cfg(test)] mod tests { use alloc::vec::Vec; @@ -70,6 +81,13 @@ mod tests { use crate::deserializer::foundation::test_support::load; use crate::deserializer::typedstream::TypedStreamDeserializer; + #[test] + fn root_object_resolves_as_i64() { + let bytes = load("foundation/NumberInt"); + let mut ts = TypedStreamDeserializer::new(&bytes); + assert_eq!(ts.root().unwrap().as_i64(), Some(42)); + } + #[test] fn as_f64_reads_decimal_double() { // NumberDouble root: bare-primitive path, exercises the B1 DECIMAL fix. diff --git a/src/deserializer/iter.rs b/src/deserializer/iter.rs index d7b99fc..b601be2 100644 --- a/src/deserializer/iter.rs +++ b/src/deserializer/iter.rs @@ -86,29 +86,44 @@ impl<'a, 'b: 'a> PropertyGroup<'a, 'b> { /// resolved against the object/type tables; everything else is a primitive. fn resolve(&self, item: &'b OutputData<'a>) -> Property<'a, 'b> { if let OutputData::Object(idx) = item - && let Some(Archived::Object { class: cls, .. }) = self.object_table.get(*idx) - && let Some(Archived::Class(cls)) = self.object_table.get(*cls) - && let Some(sub_iter) = PropertyIterator::new(self.object_table, self.type_table, *idx) + && let Some(object) = object_property(self.object_table, self.type_table, *idx) { - let class_name = self - .type_table - .get(cls.name_index) - .and_then(|types| types.first()) - .and_then(|t| match t { - Type::String(name) => Some(*name), - _ => None, - }) - .unwrap_or("Unknown Class"); - return Property::Object { - class: cls, - name: class_name, - data: sub_iter, - }; + return object; } Property::Primitive(item) } } +/// Build a [`Property::Object`] for the object at `index` in the tables, or `None` +/// if the index is not an object (or its class is missing). Shared by +/// [`PropertyGroup`] resolution and the deserializer's object/root resolvers. +pub(crate) fn object_property<'a, 'b: 'a>( + object_table: &'b [Archived<'a>], + type_table: &'b [TypeEntry<'a>], + index: usize, +) -> Option> { + let Some(Archived::Object { class: cls, .. }) = object_table.get(index) else { + return None; + }; + let Some(Archived::Class(cls)) = object_table.get(*cls) else { + return None; + }; + let data = PropertyIterator::new(object_table, type_table, index)?; + let name = type_table + .get(cls.name_index) + .and_then(|types| types.first()) + .and_then(|t| match t { + Type::String(name) => Some(*name), + _ => None, + }) + .unwrap_or("Unknown Class"); + Some(Property::Object { + class: cls, + name, + data, + }) +} + impl<'a, 'b: 'a> IntoIterator for PropertyGroup<'a, 'b> { type Item = Property<'a, 'b>; type IntoIter = PropertyGroupIter<'a, 'b>; diff --git a/src/deserializer/typedstream.rs b/src/deserializer/typedstream.rs index fea6fc4..5a08dd4 100644 --- a/src/deserializer/typedstream.rs +++ b/src/deserializer/typedstream.rs @@ -10,7 +10,7 @@ use crate::{ deserializer::{ constants::{EMPTY, END, START}, header::validate_header, - iter::PropertyIterator, + iter::{Property, PropertyIterator, object_property}, number::{read_double, read_float, read_signed_int, read_unsigned_int}, read::{read_byte_at, read_exact_bytes, read_pointer}, string::read_string, @@ -193,6 +193,58 @@ impl<'a> TypedStreamDeserializer<'a> { .ok_or(TypedStreamError::InvalidPointer(root_object_index as u8)) } + /// Resolve the object at `object_index` into a group-level [`Property`]. + /// + /// Unlike [`resolve_properties`](Self::resolve_properties) (which iterates an + /// object's *contents*), this returns the object itself, in the form the + /// `foundation` accessors expect. Use it when the value you want is an object + /// (for example the stream's root): `ts.resolve_object(root)?.as_dictionary()`. + /// + /// # Errors + /// + /// Returns [`TypedStreamError::InvalidPointer`] if the index is not an object. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let mut ts = TypedStreamDeserializer::new(&[]); + /// let root = ts.oxidize().unwrap(); + /// + /// // The object as a `Property`, ready for the `foundation` accessors. + /// let object = ts.resolve_object(root).unwrap(); + /// ``` + pub fn resolve_object(&self, object_index: usize) -> Result> { + object_property(&self.object_table, &self.type_table, object_index) + .ok_or(TypedStreamError::InvalidPointer(object_index as u8)) + } + + /// Oxidize the stream and resolve its root object into a [`Property`]. + /// + /// The object-level counterpart of [`iter_root`](Self::iter_root): use it when + /// the stream's root *is* the value you want (e.g. a root `NSDictionary`), + /// rather than a container whose contents you iterate. + /// + /// # Errors + /// + /// Returns a [`TypedStreamError`] if parsing fails or the root is invalid. + /// + /// # Examples + /// + /// ```no_run + /// use crabstep::TypedStreamDeserializer; + /// + /// let mut ts = TypedStreamDeserializer::new(&[]); + /// + /// // Resolve the root object directly, without a separate `oxidize` call. + /// let root = ts.root().unwrap(); + /// ``` + pub fn root(&mut self) -> Result> { + let root = self.oxidize()?; + self.resolve_object(root) + } + /// Reads the next byte from the stream, advancing the position. #[inline(always)] fn consume_current_byte(&mut self) -> Result<&u8> { From a1f13d26f7ea6703eb2553e630df02d99e55d3b9 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 17:00:28 -0700 Subject: [PATCH 16/17] Add test cases --- src/deserializer/foundation/array.rs | 14 ++++++++++++-- src/deserializer/foundation/date.rs | 8 ++++++++ src/deserializer/foundation/dict.rs | 14 +++++++++++++- src/deserializer/foundation/string.rs | 8 +++++++- src/deserializer/foundation/url.rs | 11 +++++++++++ src/deserializer/number.rs | 2 +- 6 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/deserializer/foundation/array.rs b/src/deserializer/foundation/array.rs index 912c4d9..43dc12f 100644 --- a/src/deserializer/foundation/array.rs +++ b/src/deserializer/foundation/array.rs @@ -173,6 +173,18 @@ mod tests { use crate::deserializer::foundation::test_support::load; use crate::deserializer::typedstream::TypedStreamDeserializer; + #[test] + fn root_object_resolves_as_array() { + // Root NSArray([NSString "a", NSNumber 1, NSString "b"]) via `root()`. + let bytes = load("foundation/NSArray"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.root().unwrap(); + let array = root.as_array().unwrap(); + assert_eq!(array.len(), 3); + let strings: Vec<&str> = array.iter().filter_map(|e| e.as_string()).collect(); + assert_eq!(strings, vec!["a", "b"]); + } + #[test] fn as_array_yields_elements_both_variants_and_empty() { // NestedContainers root holds NSArray[1,2], NSMutableArray[3], an empty @@ -211,8 +223,6 @@ mod tests { #[test] fn nested_array_inside_array() { - // NSArrayNested = [NSString "top", NSArray[1, 2]] — as_array on the nested - // element resolves the inner array. let bytes = load("foundation/NSArrayNested"); let mut ts = TypedStreamDeserializer::new(&bytes); let root = ts.oxidize().unwrap(); diff --git a/src/deserializer/foundation/date.rs b/src/deserializer/foundation/date.rs index d6f565d..005f63e 100644 --- a/src/deserializer/foundation/date.rs +++ b/src/deserializer/foundation/date.rs @@ -35,6 +35,14 @@ mod tests { use crate::deserializer::foundation::test_support::load; use crate::deserializer::typedstream::TypedStreamDeserializer; + #[test] + fn root_object_resolves_as_date() { + // Root NSDate (timeIntervalSinceReferenceDate: 21692800) via `root()`. + let bytes = load("foundation/NSDate"); + let mut ts = TypedStreamDeserializer::new(&bytes); + assert_eq!(ts.root().unwrap().as_date(), Some(21692800.0)); + } + #[test] fn as_date_and_unix_time() { // NestedScalars holds NSDate(timeIntervalSinceReferenceDate: 21692800). diff --git a/src/deserializer/foundation/dict.rs b/src/deserializer/foundation/dict.rs index 09f658a..5cfc5d0 100644 --- a/src/deserializer/foundation/dict.rs +++ b/src/deserializer/foundation/dict.rs @@ -73,7 +73,7 @@ impl<'a, 'b: 'a> FoundationDict<'a, 'b> { /// The value for a string `key`, or `None` if absent. /// /// This is a linear scan (`O(n)`); dictionaries archived in a `typedstream` - /// are typically small. Only string keys are matched — for other key types, + /// are typically small. Only string keys are matched. For other key types, /// or to build an index for many lookups, use [`iter`](Self::iter). /// /// # Examples @@ -198,6 +198,18 @@ mod tests { use crate::deserializer::foundation::test_support::load; use crate::deserializer::typedstream::TypedStreamDeserializer; + #[test] + fn root_object_resolves_as_dictionary() { + // Root NSDictionary { "k1": 1, "k2": "v" } via `root()`. + let bytes = load("foundation/NSDictionary"); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.root().unwrap(); + let dict = root.as_dictionary().unwrap(); + assert_eq!(dict.len(), 2); + assert_eq!(dict.get("k1").and_then(|v| v.as_i64()), Some(1)); + assert_eq!(dict.get("k2").and_then(|v| v.as_string()), Some("v")); + } + #[test] fn as_dictionary_yields_pairs_both_variants() { // NestedContainers holds NSDictionary{k:9} and NSMutableDictionary{mk:8}. diff --git a/src/deserializer/foundation/string.rs b/src/deserializer/foundation/string.rs index 467e38a..5973376 100644 --- a/src/deserializer/foundation/string.rs +++ b/src/deserializer/foundation/string.rs @@ -50,6 +50,13 @@ mod tests { use crate::deserializer::foundation::test_support::load; use crate::deserializer::typedstream::TypedStreamDeserializer; + #[test] + fn root_object_resolves_as_string() { + let bytes = load("foundation/NSString"); + let mut ts = TypedStreamDeserializer::new(&bytes); + assert_eq!(ts.root().unwrap().as_string(), Some("Hello, world")); + } + #[test] fn as_string_reads_both_string_variants() { // NSArray([NSString "imm", NSMutableString "mut"]) @@ -69,7 +76,6 @@ mod tests { #[test] fn as_string_reads_attributed_backing_text() { - // NSArray([NSAttributedString "styled"]) — exercises the scan path. let bytes = load("foundation/NestedAttributed"); let mut ts = TypedStreamDeserializer::new(&bytes); let root = ts.oxidize().unwrap(); diff --git a/src/deserializer/foundation/url.rs b/src/deserializer/foundation/url.rs index 2b858f8..b4822fb 100644 --- a/src/deserializer/foundation/url.rs +++ b/src/deserializer/foundation/url.rs @@ -25,6 +25,17 @@ mod tests { use crate::deserializer::foundation::test_support::load; use crate::deserializer::typedstream::TypedStreamDeserializer; + #[test] + fn root_object_resolves_as_url() { + // Root NSURL("https://example.com/path?q=1") via `root()`. + let bytes = load("foundation/NSURL"); + let mut ts = TypedStreamDeserializer::new(&bytes); + assert_eq!( + ts.root().unwrap().as_url(), + Some("https://example.com/path?q=1") + ); + } + #[test] fn as_url_absolute_and_relative() { // NestedScalars holds an absolute NSURL then a relative one; the relative diff --git a/src/deserializer/number.rs b/src/deserializer/number.rs index f20255a..b6279e9 100644 --- a/src/deserializer/number.rs +++ b/src/deserializer/number.rs @@ -422,7 +422,7 @@ mod float_tests { #[test] fn can_read_decimal_double_fractional_date() { - // `NSDate(timeIntervalSinceReferenceDate: 700000000.523)``: fractional dates + // `NSDate(timeIntervalSinceReferenceDate: 700000000.523)`: fractional dates // archive their double with the DECIMAL tag (integral ones use I_32). let data = [ DECIMAL, 0xaa, 0xf1, 0x42, 0x80, 0x93, 0xdc, 0xc4, 0x41, 0x86, From 1501e84d1e7c76ac01705767ddf9ee846d256a04 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 6 Jun 2026 17:17:04 -0700 Subject: [PATCH 17/17] Better errors for out-of-bounds root access --- src/deserializer/typedstream.rs | 22 ++++++++++++++++++---- src/lib.rs | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/deserializer/typedstream.rs b/src/deserializer/typedstream.rs index 5a08dd4..64acb47 100644 --- a/src/deserializer/typedstream.rs +++ b/src/deserializer/typedstream.rs @@ -176,7 +176,8 @@ impl<'a> TypedStreamDeserializer<'a> { /// /// # Errors /// - /// Returns [`TypedStreamError::InvalidPointer`] if the index is not a valid object reference. + /// Returns [`TypedStreamError::OutOfBounds`] if the index is past the object + /// table, or [`TypedStreamError::InvalidObject`] if it is not an object. /// /// # Examples /// @@ -189,8 +190,14 @@ impl<'a> TypedStreamDeserializer<'a> { /// let iter = ts.resolve_properties(root).unwrap(); /// ``` pub fn resolve_properties(&self, root_object_index: usize) -> Result> { + if root_object_index >= self.object_table.len() { + return Err(TypedStreamError::OutOfBounds( + root_object_index, + self.object_table.len(), + )); + } PropertyIterator::new(&self.object_table, &self.type_table, root_object_index) - .ok_or(TypedStreamError::InvalidPointer(root_object_index as u8)) + .ok_or(TypedStreamError::InvalidObject) } /// Resolve the object at `object_index` into a group-level [`Property`]. @@ -202,7 +209,8 @@ impl<'a> TypedStreamDeserializer<'a> { /// /// # Errors /// - /// Returns [`TypedStreamError::InvalidPointer`] if the index is not an object. + /// Returns [`TypedStreamError::OutOfBounds`] if the index is past the object + /// table, or [`TypedStreamError::InvalidObject`] if it is not an object. /// /// # Examples /// @@ -216,8 +224,14 @@ impl<'a> TypedStreamDeserializer<'a> { /// let object = ts.resolve_object(root).unwrap(); /// ``` pub fn resolve_object(&self, object_index: usize) -> Result> { + if object_index >= self.object_table.len() { + return Err(TypedStreamError::OutOfBounds( + object_index, + self.object_table.len(), + )); + } object_property(&self.object_table, &self.type_table, object_index) - .ok_or(TypedStreamError::InvalidPointer(object_index as u8)) + .ok_or(TypedStreamError::InvalidObject) } /// Oxidize the stream and resolve its root object into a [`Property`]. diff --git a/src/lib.rs b/src/lib.rs index 0e67d66..5705a18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,30 @@ mod test_typedstream_deserializer { }, }; + #[test] + fn resolve_object_and_properties_reject_out_of_bounds() { + let typedstream_path = current_dir() + .unwrap() + .as_path() + .join("src/test_data/AttributedBodyTextOnly"); + let mut file = File::open(typedstream_path).unwrap(); + let mut bytes = vec![]; + file.read_to_end(&mut bytes).unwrap(); + let mut ts = TypedStreamDeserializer::new(&bytes); + ts.oxidize().unwrap(); + + // An index past the object table reports the full index via OutOfBounds, + // not a `usize`-to-`u8`-truncated InvalidPointer. + assert!(matches!( + ts.resolve_object(9999), + Err(crate::error::TypedStreamError::OutOfBounds(9999, _)) + )); + assert!(matches!( + ts.resolve_properties(9999), + Err(crate::error::TypedStreamError::OutOfBounds(9999, _)) + )); + } + #[test] fn test_parse_text_iter() { let typedstream_path = current_dir()