Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
2eb4e2a
Add new binary test data files for various new categories
ReagentX Jun 6, 2026
30f2747
Merge pull request #19 from ReagentX/feat/cs/add-tests
ReagentX Jun 6, 2026
6a10c74
Add constant for i64/u64 byte stream indicator
ReagentX Jun 6, 2026
ff8522e
Add support for reading 64-bit signed and unsigned integers from byte…
ReagentX Jun 6, 2026
eada708
Merge pull request #22 from ReagentX/feat/cs/fix-number-parse-byg
ReagentX Jun 6, 2026
8c3f8aa
Fix comment format
ReagentX Jun 6, 2026
d76066e
Add foundation feature scaffold
ReagentX Jun 6, 2026
23c11a2
Add foundation test data
ReagentX Jun 6, 2026
80a1e3b
Add nested test data
ReagentX Jun 6, 2026
d8e51c4
Implement typed accessors for `Foundation` classes
ReagentX Jun 6, 2026
f8f1d34
Add nested container tests
ReagentX Jun 6, 2026
19b8df5
Add accessors for Foundation collections: arrays, sets, and dictionaries
ReagentX Jun 6, 2026
7d5b469
Add support for NestedScalars in Foundation typedstream test fixtures
ReagentX Jun 6, 2026
211bfd7
Add date, URL, and null accessors for Foundation properties
ReagentX Jun 6, 2026
3147644
Include new crate features in ci tests
ReagentX Jun 6, 2026
56101c6
Improve Foundation accessors with lazy views for arrays and dictionaries
ReagentX Jun 6, 2026
84002b8
Re-export `Property`
ReagentX Jun 6, 2026
88699cd
Refactor into type modules
ReagentX Jun 6, 2026
bdfa499
Add build steps for foundation features in CI workflows
ReagentX Jun 6, 2026
a9a874b
Update docs for Foundation accessors; improve error handling in deser…
ReagentX Jun 6, 2026
a1f13d2
Add test cases
ReagentX Jun 7, 2026
1501e84
Better errors for out-of-bounds root access
ReagentX Jun 7, 2026
cf74d4c
Merge pull request #23 from ReagentX/feat/cs/foundation-api
ReagentX Jun 7, 2026
b8f969e
Replace inner vectors with `One(OutputData) | Many(Vec<OutputData>)`
ReagentX Sep 16, 2026
e9b6d84
Fix stream desync for embedded structures like `encodeValuesOfObjCTyp…
ReagentX Sep 16, 2026
0982983
Refactor DataGroup and ObjectData to reduce indirection
ReagentX Sep 16, 2026
3e7a37c
Merge pull request #24 from ReagentX/feat/cs/prioritize-group-storage
ReagentX Sep 16, 2026
6966a76
Add support for method selectors in typedstream deserialization
ReagentX Sep 16, 2026
ead337b
Fix typo in Utf8String type encoding documentation
ReagentX Sep 16, 2026
ad68a15
Add test for parsing AppKit nib files and include sample nib data
ReagentX Sep 16, 2026
0db647d
Merge pull request #25 from ReagentX/feat/cs/support-selector-type
ReagentX Sep 16, 2026
5ed00f8
Add support for reading aggregates in typedstream deserialization
ReagentX Sep 16, 2026
f898fb0
Remove ARRAY constant from byte stream indicators
ReagentX Sep 16, 2026
c8ac0f5
Enhance TypedStreamDeserializer to handle class references and aggreg…
ReagentX Sep 16, 2026
7aa8513
Refactor OutputData enum: remove Byte variant and update Object refer…
ReagentX Sep 16, 2026
0ac9ea4
Add tests for parsing NSValue structs and include sample data files
ReagentX Sep 16, 2026
e92b154
Refactor TypedStreamDeserializer to support shared strings and update…
ReagentX Sep 16, 2026
5663225
Update test cases to retrieve last group in NSNumber properties
ReagentX Sep 16, 2026
8c8c83c
Add new binary file CStrings to test data foundation, update test fix…
ReagentX Sep 16, 2026
a2c3bab
Merge pull request #26 from ReagentX/feat/cs/parse-struct-type-encodings
ReagentX Sep 16, 2026
2128223
Add `SharedString` struct for managing shared strings in deserialization
ReagentX Sep 16, 2026
2fd9500
Refactor documentation for CString and Class name reference; update O…
ReagentX Sep 16, 2026
aab95ea
Refactor `PropertyGroup` and `PropertyIterator` to use `SharedString`…
ReagentX Sep 16, 2026
dfcac73
Refactor `TypedStreamDeserializer` to replace `type_table` and `strin…
ReagentX Sep 16, 2026
e46cbde
Refactor `Type` and `TypeEntry` enums to remove lifetime parameters a…
ReagentX Sep 16, 2026
586b965
Simplify test cases for new `SharedString` equality test
ReagentX Sep 16, 2026
021e248
Merge pull request #27 from ReagentX/feat/cs/shared-string-type
ReagentX Sep 16, 2026
03d318b
Add support for `Atom` type in `TypedStreamDeserializer` and related …
ReagentX Sep 16, 2026
b126e84
Merge pull request #28 from ReagentX/feat/cs/support-atom-type
ReagentX Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@ categories = ["parsing", "parser-implementations", "database"]
[dependencies]

[features]
std=[]
std = []
foundation = []

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down
5 changes: 3 additions & 2 deletions src/deserializer/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
pub const I_16: u8 = 0x81;
/// Indicates an [`i32`] in the byte stream
pub const I_32: u8 = 0x82;
/// Indicates an [`i64`]/[`u64`] (8-byte) in the byte stream, used for `NSNumber`
/// values whose magnitude does not fit a 32-bit integer (C type `q`/`Q`)
pub const I_64: u8 = 0x87;
/// Indicates an [`f32`] or [`f64`] in the byte stream; the [`Type`](crate::models::types::Type) determines the size
pub const DECIMAL: u8 = 0x83;
/// Indicates the start of a new object
Expand All @@ -14,5 +17,3 @@ pub const EMPTY: u8 = 0x85;
pub const END: u8 = 0x86;
/// Bytes equal or greater in value than the reference tag indicate an index in the table of already-seen types
pub const REFERENCE_TAG: u64 = 0x92;
/// Indicates an array in the byte stream
pub const ARRAY: u8 = 0x5b;
288 changes: 288 additions & 0 deletions src/deserializer/foundation/array.rs
Original file line number Diff line number Diff line change
@@ -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<FoundationArray<'a, 'b>> {
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<FoundationArray<'a, 'b>> {
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<Property<'a, 'b>> {
self.iter().nth(index)
}

/// The first element.
#[must_use]
pub fn first(&self) -> Option<Property<'a, 'b>> {
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::Item> {
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<Vec<i64>> = 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<Vec<&str>> = 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<Vec<i64>> = 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);
}
}
34 changes: 34 additions & 0 deletions src/deserializer/foundation/boolean.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! `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<bool> {
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();
// Root `NSNumber` groups: `objCType` first, numeric value last.
let group = ts.resolve_properties(root).unwrap().last().unwrap();

assert_eq!(group.as_bool(), Some(true));
assert_eq!(group.as_i64(), Some(1));
}
}
Loading
Loading