From 03d318b854350078427673c7bb2feec6ee8c1985 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Wed, 16 Sep 2026 16:51:15 -0700 Subject: [PATCH] Add support for `Atom` type in `TypedStreamDeserializer` and related tests --- src/deserializer/typedstream.rs | 34 +++++++++++++++++++++++++-- src/lib.rs | 40 ++++++++++++++++++++++++++++++++ src/models/types.rs | 12 ++++++++++ src/test_data/foundation/Atoms | Bin 0 -> 60 bytes 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/test_data/foundation/Atoms diff --git a/src/deserializer/typedstream.rs b/src/deserializer/typedstream.rs index f581fdf..4ab4ab0 100644 --- a/src/deserializer/typedstream.rs +++ b/src/deserializer/typedstream.rs @@ -449,8 +449,10 @@ impl<'a> TypedStreamDeserializer<'a> { self.position += length; Ok(OutputData::Array(array_data)) } - // Selector encoding: one literal, then shared-string references. - Type::Selector => { + // Selector and atom encoding: one literal, then shared-string + // references. Both are unique by text, so neither takes an + // object-table slot. + Type::Selector | Type::Atom => { if *read_byte_at(self.data, self.position)? == EMPTY { self.position += 1; return Ok(OutputData::Null); @@ -720,6 +722,34 @@ mod group_tests { assert_eq!(ts.position, bytes.len()); } + #[test] + fn atoms_are_shared_strings_without_object_slots() { + // The layout NSArchiver writes for `%`: a literal, then a reference by + // string index, then NULL. Descriptor `%%` is entry 2 and `atom` entry + // 3 (tag 0x95). Unlike a `char *`, no object-table slot is taken. + let bytes = stream(&[ + START, 2, b'%', b'%', START, 4, b'a', b't', b'o', b'm', 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("atom"), OutputData::String("atom"),]), + DataGroup::One(OutputData::Null), + ]) + ); + assert!( + !ts.object_table + .iter() + .any(|o| matches!(o, Archived::CString(_))) + ); + assert_eq!(ts.position, bytes.len()); + } + #[test] fn selectors_are_shared_strings() { // Descriptor `::` is string-table entry 2, the literal `quit:` entry 3, diff --git a/src/lib.rs b/src/lib.rs index f849bfc..ed6dfa5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8858,6 +8858,46 @@ mod test_typedstream_deserializer { assert_eq!(slots[0], slots[1]); } + /// `%` (`NXAtom`) slots from the classic archiver (`Atoms` in + /// `test_data/generators/foundation.swift`): a literal, `NULL`, the same + /// atom again, then an `int`. + #[test] + fn test_parse_atoms() { + let typedstream_path = current_dir() + .unwrap() + .as_path() + .join("src/test_data/foundation/Atoms"); + 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(); + assert_eq!(ts.position, bytes.len()); + let Archived::Object { data, .. } = &ts.object_table[root] else { + panic!("root is not an object"); + }; + assert_eq!( + data, + &ObjectData::Groups(vec![ + DataGroup::One(OutputData::String("atom")), + DataGroup::One(OutputData::Null), + DataGroup::One(OutputData::String("atom")), + DataGroup::One(OutputData::SignedInteger(7)), + ]) + ); + assert!( + !ts.object_table + .iter() + .any(|o| matches!(o, Archived::CString(_))), + "an atom is shared by text, not by pointer" + ); + assert_eq!( + ts.string_table.iter().filter(|e| e.text == "atom").count(), + 1 + ); + } + /// Which shared strings carry a descriptor view after a parse: descriptor /// literals eagerly, class names never, and an `NSNumber`'s `objCType` on /// the reference that types its payload. diff --git a/src/models/types.rs b/src/models/types.rs index 56cd0c9..c6860f6 100644 --- a/src/models/types.rs +++ b/src/models/types.rs @@ -30,6 +30,16 @@ pub enum Type { /// |--------|-------| /// | `0x3A` | [`:`](https://www.compart.com/en/unicode/U+003A) | Selector, + /// A `NeXTSTEP` atom (`NXAtom`): a uniqued C string, the ancestor of `SEL` + /// interning. Since an atom is unique by text it is written exactly like + /// a selector: a shared string, with + /// [`EMPTY`](crate::deserializer::constants::EMPTY) for `NULL` and no + /// object-table slot. Denoted by: + /// + /// | Hex | UTF-8 | + /// |--------|-------| + /// | `0x25` | [`%`](https://www.compart.com/en/unicode/U+0025) | + Atom, /// An instance of a class, usually with data. Denoted by: /// /// | Hex | UTF-8 | @@ -127,6 +137,7 @@ impl Type { b'@' => Self::Object, b'#' => Self::Class, b':' => Self::Selector, + b'%' => Self::Atom, b'*' => Self::CString, b'+' => Self::Utf8String, b'f' => Self::Float, @@ -308,6 +319,7 @@ mod type_encoding_tests { ("@", Type::Object), ("#", Type::Class), (":", Type::Selector), + ("%", Type::Atom), ("*", Type::CString), ("+", Type::Utf8String), ("f", Type::Float), diff --git a/src/test_data/foundation/Atoms b/src/test_data/foundation/Atoms new file mode 100644 index 0000000000000000000000000000000000000000..acb0854978774020555166cd283148469988c036 GIT binary patch literal 60 zcmZSKE-oobP0TH+EJ#ghe8Jqp=+M&A!s=L(pIgk((!${v?4OjCnq0!r+QO*X!jcFS OpV~TgS_@+)dm8|gmKC@F literal 0 HcmV?d00001