Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 62 additions & 4 deletions src/deserializer/typedstream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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`
Expand Down
143 changes: 142 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<usize> = (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());
}
}
11 changes: 10 additions & 1 deletion src/models/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,22 @@ 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:
///
/// | Hex | UTF-8 |
/// |--------|-------|
/// | `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 |
Expand Down Expand Up @@ -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,
Expand Down
Binary file added src/test_data/SDLMainNib
Binary file not shown.
Loading