From 6966a76bdd708d8012771c22d4d8c5b3754736ab Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 15 Sep 2026 23:07:02 -0700 Subject: [PATCH 1/3] Add support for method selectors in typedstream deserialization --- src/deserializer/typedstream.rs | 66 +++++++++++++++++++++++++++++++-- src/models/types.rs | 9 +++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/deserializer/typedstream.rs b/src/deserializer/typedstream.rs index 16d118a..8012eb8 100644 --- a/src/deserializer/typedstream.rs +++ b/src/deserializer/typedstream.rs @@ -384,10 +384,9 @@ impl<'a> TypedStreamDeserializer<'a> { } Ok(Some(placeholder_index)) } - EMPTY => { - self.position += 1; - Ok(None) - } + // A nil reference and a pointer are both one byte; like the `END` + // of an inline object, that byte is left for the caller to consume. + EMPTY => Ok(None), ptr => { let pointer = read_pointer(&ptr)?; Ok(Some(pointer.value as usize)) @@ -449,6 +448,19 @@ impl<'a> TypedStreamDeserializer<'a> { self.position += length; Ok(OutputData::Array(array_data)) } + // Selectors share the class-name string table, so a repeated + // selector arrives as a reference and resolves through it. + Type::Selector => { + if *read_byte_at(self.data, self.position)? == EMPTY { + self.position += 1; + return Ok(OutputData::Null); + } + let name_idx = self.read_string()?; + match self.type_table[name_idx].first() { + Some(Type::String(selector)) => Ok(OutputData::String(selector)), + _ => Err(TypedStreamError::InvalidObject), + } + } Type::Unknown(byte) => Ok(OutputData::Byte(byte)), // Handle all numeric types Type::SignedInt | Type::UnsignedInt | Type::Float | Type::Double => { @@ -698,6 +710,52 @@ mod group_tests { } } + #[test] + fn nil_object_reference_consumes_one_byte() { + // `@` + EMPTY is a one-byte nil. The slot after it must still line up: + // consuming a second byte would swallow the `7`. + let bytes = stream(&[START, 2, b'@', b'C', EMPTY, 7]); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let Archived::Object { data, .. } = &ts.object_table[root] else { + panic!("expected an object"); + }; + assert_eq!( + data, + &ObjectData::Groups(vec![DataGroup::Values(vec![ + OutputData::Null, + OutputData::UnsignedInteger(7), + ])]) + ); + assert_eq!(ts.position, bytes.len()); + } + + #[test] + fn selectors_are_shared_strings() { + // Descriptor `::` is string-table entry 2, the literal `quit:` entry 3, + // so the second selector references it with tag 0x92 + 3. + let bytes = stream(&[ + START, 2, b':', b':', START, 5, b'q', b'u', b'i', b't', b':', 0x95, START, 1, b':', + EMPTY, + ]); + let mut ts = TypedStreamDeserializer::new(&bytes); + let root = ts.oxidize().unwrap(); + let Archived::Object { data, .. } = &ts.object_table[root] else { + panic!("expected an object"); + }; + assert_eq!( + data, + &ObjectData::Groups(vec![ + DataGroup::Values(vec![ + OutputData::String("quit:"), + OutputData::String("quit:"), + ]), + DataGroup::One(OutputData::Null), + ]) + ); + assert_eq!(ts.position, bytes.len()); + } + #[test] fn splices_embedded_values_into_multi_slot_groups() { // A descriptor with several slots fills one group; an `EmbeddedData` diff --git a/src/models/types.rs b/src/models/types.rs index e1e1836..1068822 100644 --- a/src/models/types.rs +++ b/src/models/types.rs @@ -24,6 +24,14 @@ pub enum Type<'a> { /// |--------|-------| /// | `0x2A` | [`*`](https://www.compart.com/en/unicode/U+002A) | EmbeddedData, + /// A method selector (`SEL`), written as a shared string: the first + /// occurrence is a literal, later ones are references to it, and a `NULL` + /// selector is [`EMPTY`](crate::deserializer::constants::EMPTY). Denoted by: + /// + /// | Hex | UTF-8 | + /// |--------|-------| + /// | `0x3A` | [`:`](https://www.compart.com/en/unicode/U+003A) | + Selector, /// An instance of a class, usually with data. Denoted by: /// /// | Hex | UTF-8 | @@ -80,6 +88,7 @@ impl<'a> Type<'a> { 0x40 => Self::Object, 0x2B => Self::Utf8String, 0x2A => Self::EmbeddedData, + 0x3A => Self::Selector, 0x66 => Self::Float, 0x64 => Self::Double, 0x63 | 0x69 | 0x6c | 0x71 | 0x73 => Self::SignedInt, From ead337b1c7b0acc518ea6a29ed62ab9c1898e096 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 15 Sep 2026 23:07:07 -0700 Subject: [PATCH 2/3] Fix typo in Utf8String type encoding documentation --- src/models/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/types.rs b/src/models/types.rs index 1068822..ed17c06 100644 --- a/src/models/types.rs +++ b/src/models/types.rs @@ -16,7 +16,7 @@ pub enum Type<'a> { /// /// | Hex | UTF-8 | /// |--------|-------| - /// | `0x28` | [`+`](https://www.compart.com/en/unicode/U+002B) | + /// | `0x2B` | [`+`](https://www.compart.com/en/unicode/U+002B) | Utf8String, /// Encoded bytes that can be parsed again as data. Denoted by: /// From ad68a15f0255cf673144d7d361e46b7e146e6a40 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 15 Sep 2026 23:07:14 -0700 Subject: [PATCH 3/3] Add test for parsing AppKit nib files and include sample nib data --- src/lib.rs | 143 ++++++++++++++++++++++++++++++++++++++- src/test_data/SDLMainNib | Bin 0 -> 1912 bytes 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/test_data/SDLMainNib diff --git a/src/lib.rs b/src/lib.rs index f0b47f6..3b4b893 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ pub use models::{ #[cfg(test)] mod test_typedstream_deserializer { extern crate std; - use alloc::vec; + use alloc::{vec, vec::Vec}; use std::{env::current_dir, fs::File, io::Read, println}; use crate::{ @@ -8288,4 +8288,145 @@ mod test_typedstream_deserializer { assert_eq!(typedstream.type_table, expected_types); assert_eq!(typedstream.object_table, expected_objects); } + + /// A pre-keyed AppKit nib (`SDLMain.nib/objects.nib`, from the SDL project's + /// Mac template). + #[test] + fn test_parse_nib() { + let typedstream_path = current_dir() + .unwrap() + .as_path() + .join("src/test_data/SDLMainNib"); + 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); + + let root = ts.oxidize().unwrap(); + // Byte-exact: every byte was consumed and nothing was left half-read. + assert_eq!(ts.position, bytes.len()); + assert!( + !ts.object_table + .iter() + .any(|o| matches!(o, Archived::Placeholder)), + "an object was entered but never completed" + ); + + let class_name = |idx: usize| match &ts.object_table[idx] { + Archived::Class(class) => { + ts.type_table[class.name_index] + .first() + .and_then(|t| match t { + Type::String(name) => Some(*name), + _ => None, + }) + } + _ => None, + }; + let Archived::Object { class, .. } = &ts.object_table[root] else { + panic!("root is not an object"); + }; + assert_eq!(class_name(*class), Some("NSIBObjectData")); + + let mut classes: Vec<&str> = (0..ts.object_table.len()).filter_map(class_name).collect(); + classes.sort_unstable(); + assert_eq!( + classes, + [ + "NSArray", + "NSCustomObject", + "NSCustomResource", + "NSIBObjectData", + "NSMenu", + "NSMenuItem", + "NSMutableArray", + "NSMutableSet", + "NSMutableString", + "NSNibConnector", + "NSNibControlConnector", + "NSNibOutletConnector", + "NSObject", + "NSSet", + "NSString", + ] + ); + + // Every value an object holds, whichever storage shape it took. + fn values_of<'v, 'a>(data: &'v ObjectData<'a>) -> Vec<&'v OutputData<'a>> { + match data { + ObjectData::Empty => Vec::new(), + ObjectData::Inline(value) => vec![value], + ObjectData::Groups(groups) => { + groups.iter().flat_map(|group| group.as_slice()).collect() + } + } + } + let values: Vec<&OutputData<'_>> = ts + .object_table + .iter() + .filter_map(|o| match o { + Archived::Object { data, .. } => Some(values_of(data)), + _ => None, + }) + .flatten() + .collect(); + let strings: Vec<&str> = values.iter().filter_map(|v| v.as_str()).collect(); + for selector in [ + "submenuAction:", + "orderFrontStandardAboutPanel:", + "performMiniaturize:", + "unhideAllApplications:", + "quit:", + ] { + assert!(strings.contains(&selector), "missing selector {selector}"); + } + for title in [ + "MainMenu", + "Quit DEFCON", + "Hide Others", + "Minimize", + "User Manual", + ] { + assert!(strings.contains(&title), "missing title {title}"); + } + // Nil outlets, images, and targets: the path Messages data never takes. + let nils = values + .iter() + .filter(|v| matches!(v, OutputData::Null)) + .count(); + assert!(nils >= 20, "expected many nil references, found {nils}"); + // No byte fell through as an unrecognized type. + assert!(!values.iter().any(|v| matches!(v, OutputData::Byte(_)))); + + // Every object is reachable from the root through object references, + // including the back-references from menu items to their menus. + let mut seen = vec![false; ts.object_table.len()]; + let mut stack = vec![root]; + while let Some(idx) = stack.pop() { + if core::mem::replace(&mut seen[idx], true) { + continue; + } + if let Archived::Object { data, .. } = &ts.object_table[idx] { + for value in values_of(data) { + if let OutputData::Object(child) = value { + stack.push(*child); + } + } + } + } + let unreachable: Vec = (0..ts.object_table.len()) + .filter(|&i| !seen[i] && matches!(ts.object_table[i], Archived::Object { .. })) + .collect(); + assert!( + unreachable.is_empty(), + "unreachable objects: {unreachable:?}" + ); + + // The lazy property view agrees with the table on the root's shape. + let root_groups = ts.resolve_properties(root).unwrap().count(); + let Archived::Object { data, .. } = &ts.object_table[root] else { + unreachable!() + }; + assert_eq!(root_groups, data.group_count()); + } } diff --git a/src/test_data/SDLMainNib b/src/test_data/SDLMainNib new file mode 100644 index 0000000000000000000000000000000000000000..3b7ed12fc83ef49f445b919ab9714665218f5d6b GIT binary patch literal 1912 zcma)7-D@LN6u;B24NbPTxPE{Z@}g^9U)Cq>Q@ZVj4cJs2#K&ct+)Zb9KC+V;YJHKr z++2JT49R6XX0g<&^$w+_lr8k31iKI@1#ABTK}AIr5fKrjo;x$`Mp~_X>2S{Pch2ve zd(KHuvf?^eGu^o|E|TxNsgj|T4&>F`m8t2QxZqA`u13B>lqT|OWI@$h1R3`#u5Cuf zkvi?lNa`bbb<%USX#=aSqgy2}QJsuC2)HLYc z<*h;@$eo1Ta(biGA-C^M-74C%|DE&*1Zlfu+IC1yOzM_y>Tlyyb3VikUA0`$m-P%I ztri@NVSAuUH~y{D(mMc3BW0DiPfdP5;A;Wj2zax>gGIg`@RfkKeZCs-ofhA2^1U|S z3ix7^@3uMR;pQBzYn3(lX24t9u>%f4*0x;e8SIqq+o|^3<;Fe)OBAtzOW`q-5_YSg z_0ckRZrF|~$4_%T2PQC1w<#&Tw_&AY!*<5kob<&kXR!OiPlZ=HF*z- zHW^mLc1if;X+``<=8^b;+(qJdGNOoI$wfu1k_Sj^k!KX~C%L4Eh9cI<`$+sk(u&w6 zPbp%Pyo*HevN#}rA+ckNJ@Tv~7GDy-k>?b#Pd-OtgK#8%B0ESlhsDaMPhL=bRD*5} T=B|v}1zXEHnu%v^`HS&4P;5>n literal 0 HcmV?d00001