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/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/models/types.rs b/src/models/types.rs index e1e1836..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: /// @@ -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, diff --git a/src/test_data/SDLMainNib b/src/test_data/SDLMainNib new file mode 100644 index 0000000..3b7ed12 Binary files /dev/null and b/src/test_data/SDLMainNib differ