diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be41d2..7dc1537 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,10 @@ jobs: steps: - uses: actions/checkout@v4 - run: rustup update stable && rustup default stable - - run: cargo test --verbose + - 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 }} sed -i "s/0.0.0/$VERSION/g" Cargo.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4001a7e..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 clippy - - run: cargo test --verbose + - 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 diff --git a/Cargo.toml b/Cargo.toml index 085ea7d..3cc6a10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,9 @@ categories = ["parsing", "parser-implementations", "database"] [dependencies] [features] -std=[] \ 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..6015a42 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](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`: + +```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/array.rs b/src/deserializer/foundation/array.rs new file mode 100644 index 0000000..43dc12f --- /dev/null +++ b/src/deserializer/foundation/array.rs @@ -0,0 +1,288 @@ +//! `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 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 + // 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() { + 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..8198f3c --- /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, 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]> { + 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..005f63e --- /dev/null +++ b/src/deserializer/foundation/date.rs @@ -0,0 +1,82 @@ +//! `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 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). + 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..5cfc5d0 --- /dev/null +++ b/src/deserializer/foundation/dict.rs @@ -0,0 +1,259 @@ +//! `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 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}. + 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..cc70baf --- /dev/null +++ b/src/deserializer/foundation/helpers.rs @@ -0,0 +1,32 @@ +//! 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> { + 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, + } + } +} + +/// 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()?; + // 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 new file mode 100644 index 0000000..028218f --- /dev/null +++ b/src/deserializer/foundation/mod.rs @@ -0,0 +1,42 @@ +/*! +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 [`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; +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..edc46b4 --- /dev/null +++ b/src/deserializer/foundation/number.rs @@ -0,0 +1,185 @@ +//! `as_i64` / `as_u64` / `as_f64`: numeric `NSNumber`s (or bare primitives). + +use crate::deserializer::iter::{Property, PropertyIterator}; +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>> { + match self { + Property::Primitive(value) => Some(value), + Property::Object { + name: "NSNumber", + data, + .. + } => 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, + } + } +} + +/// 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; + + 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. + 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..5973376 --- /dev/null +++ b/src/deserializer/foundation/string.rs @@ -0,0 +1,106 @@ +//! `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 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"]) + 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() { + 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..b4822fb --- /dev/null +++ b/src/deserializer/foundation/url.rs @@ -0,0 +1,54 @@ +//! `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 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 + // 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"]); + } +} 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/mod.rs b/src/deserializer/mod.rs index c3e1227..5b86d8a 100644 --- a/src/deserializer/mod.rs +++ b/src/deserializer/mod.rs @@ -2,6 +2,9 @@ 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; pub mod number; diff --git a/src/deserializer/number.rs b/src/deserializer/number.rs index 268aafd..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, diff --git a/src/deserializer/typedstream.rs b/src/deserializer/typedstream.rs index fea6fc4..64acb47 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, @@ -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,73 @@ 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`]. + /// + /// 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::OutOfBounds`] if the index is past the object + /// table, or [`TypedStreamError::InvalidObject`] if it 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> { + 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::InvalidObject) + } + + /// 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. diff --git a/src/lib.rs b/src/lib.rs index 0ed20f5..5705a18 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; @@ -10,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, @@ -33,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() diff --git a/src/test_data/foundation/NSArray b/src/test_data/foundation/NSArray new file mode 100644 index 0000000..56670c3 Binary files /dev/null and b/src/test_data/foundation/NSArray differ diff --git a/src/test_data/foundation/NSArrayEmpty b/src/test_data/foundation/NSArrayEmpty new file mode 100644 index 0000000..71b3135 Binary files /dev/null and b/src/test_data/foundation/NSArrayEmpty differ diff --git a/src/test_data/foundation/NSArrayNested b/src/test_data/foundation/NSArrayNested new file mode 100644 index 0000000..7929afe Binary files /dev/null and b/src/test_data/foundation/NSArrayNested differ diff --git a/src/test_data/foundation/NSArrayWithNull b/src/test_data/foundation/NSArrayWithNull new file mode 100644 index 0000000..988a9a3 Binary files /dev/null and b/src/test_data/foundation/NSArrayWithNull differ diff --git a/src/test_data/foundation/NSAttributedString b/src/test_data/foundation/NSAttributedString new file mode 100644 index 0000000..7849f94 Binary files /dev/null and b/src/test_data/foundation/NSAttributedString differ diff --git a/src/test_data/foundation/NSData b/src/test_data/foundation/NSData new file mode 100644 index 0000000..52f5657 Binary files /dev/null and b/src/test_data/foundation/NSData differ diff --git a/src/test_data/foundation/NSDate b/src/test_data/foundation/NSDate new file mode 100644 index 0000000..e2cab08 Binary files /dev/null and b/src/test_data/foundation/NSDate differ diff --git a/src/test_data/foundation/NSDateFractional b/src/test_data/foundation/NSDateFractional new file mode 100644 index 0000000..b590f35 Binary files /dev/null and b/src/test_data/foundation/NSDateFractional differ diff --git a/src/test_data/foundation/NSDictionary b/src/test_data/foundation/NSDictionary new file mode 100644 index 0000000..6ab0795 Binary files /dev/null and b/src/test_data/foundation/NSDictionary differ diff --git a/src/test_data/foundation/NSDictionaryNested b/src/test_data/foundation/NSDictionaryNested new file mode 100644 index 0000000..668066a Binary files /dev/null and b/src/test_data/foundation/NSDictionaryNested differ diff --git a/src/test_data/foundation/NSMutableArray b/src/test_data/foundation/NSMutableArray new file mode 100644 index 0000000..40bbfe6 Binary files /dev/null and b/src/test_data/foundation/NSMutableArray differ diff --git a/src/test_data/foundation/NSMutableData b/src/test_data/foundation/NSMutableData new file mode 100644 index 0000000..cb9ee0d Binary files /dev/null and b/src/test_data/foundation/NSMutableData differ diff --git a/src/test_data/foundation/NSMutableDictionary b/src/test_data/foundation/NSMutableDictionary new file mode 100644 index 0000000..66b9189 Binary files /dev/null and b/src/test_data/foundation/NSMutableDictionary differ diff --git a/src/test_data/foundation/NSMutableSet b/src/test_data/foundation/NSMutableSet new file mode 100644 index 0000000..200a16d Binary files /dev/null and b/src/test_data/foundation/NSMutableSet differ diff --git a/src/test_data/foundation/NSMutableString b/src/test_data/foundation/NSMutableString new file mode 100644 index 0000000..239177d Binary files /dev/null and b/src/test_data/foundation/NSMutableString differ diff --git a/src/test_data/foundation/NSSet b/src/test_data/foundation/NSSet new file mode 100644 index 0000000..99b1d23 Binary files /dev/null and b/src/test_data/foundation/NSSet differ diff --git a/src/test_data/foundation/NSString b/src/test_data/foundation/NSString new file mode 100644 index 0000000..03c0d57 Binary files /dev/null and b/src/test_data/foundation/NSString differ diff --git a/src/test_data/foundation/NSURL b/src/test_data/foundation/NSURL new file mode 100644 index 0000000..df613a8 Binary files /dev/null and b/src/test_data/foundation/NSURL differ diff --git a/src/test_data/foundation/NSURLRelative b/src/test_data/foundation/NSURLRelative new file mode 100644 index 0000000..298a6b9 Binary files /dev/null and b/src/test_data/foundation/NSURLRelative differ diff --git a/src/test_data/foundation/NestedAttributed b/src/test_data/foundation/NestedAttributed new file mode 100644 index 0000000..70b8697 Binary files /dev/null and b/src/test_data/foundation/NestedAttributed differ diff --git a/src/test_data/foundation/NestedContainers b/src/test_data/foundation/NestedContainers new file mode 100644 index 0000000..3ad54ff Binary files /dev/null and b/src/test_data/foundation/NestedContainers differ diff --git a/src/test_data/foundation/NestedData b/src/test_data/foundation/NestedData new file mode 100644 index 0000000..212f2fa Binary files /dev/null and b/src/test_data/foundation/NestedData differ diff --git a/src/test_data/foundation/NestedScalars b/src/test_data/foundation/NestedScalars new file mode 100644 index 0000000..ebf2348 Binary files /dev/null and b/src/test_data/foundation/NestedScalars differ diff --git a/src/test_data/foundation/NestedStrings b/src/test_data/foundation/NestedStrings new file mode 100644 index 0000000..0ae47f6 Binary files /dev/null and b/src/test_data/foundation/NestedStrings differ diff --git a/src/test_data/foundation/NumberBool b/src/test_data/foundation/NumberBool new file mode 100644 index 0000000..3b5e3b9 Binary files /dev/null and b/src/test_data/foundation/NumberBool differ diff --git a/src/test_data/foundation/NumberDouble b/src/test_data/foundation/NumberDouble new file mode 100644 index 0000000..9661076 Binary files /dev/null and b/src/test_data/foundation/NumberDouble differ diff --git a/src/test_data/foundation/NumberFloat b/src/test_data/foundation/NumberFloat new file mode 100644 index 0000000..bc9553e Binary files /dev/null and b/src/test_data/foundation/NumberFloat differ diff --git a/src/test_data/foundation/NumberInt b/src/test_data/foundation/NumberInt new file mode 100644 index 0000000..dace512 Binary files /dev/null and b/src/test_data/foundation/NumberInt differ diff --git a/src/test_data/foundation/NumberInt64 b/src/test_data/foundation/NumberInt64 new file mode 100644 index 0000000..1e906a2 Binary files /dev/null and b/src/test_data/foundation/NumberInt64 differ diff --git a/test_data/generators/foundation.swift b/test_data/generators/foundation.swift new file mode 100644 index 0000000..8eb848b --- /dev/null +++ b/test_data/generators/foundation.swift @@ -0,0 +1,99 @@ +// 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/")) + // 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")]) + 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")]), + ]) + 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 + } +} + +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..9279932 --- /dev/null +++ b/test_data/generators/generate.sh @@ -0,0 +1,33 @@ +#!/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 \ +NestedStrings NestedData NestedAttributed NestedContainers NestedScalars" + +for k in $keys; do + "$bin" "$k" "$out/$k" || echo "FAILED: $k" +done + +echo "Fixtures written to $out"