diff --git a/crates/flutterdec-serwalker/src/cluster/mod.rs b/crates/flutterdec-serwalker/src/cluster/mod.rs index 4b16fde..2662f47 100644 --- a/crates/flutterdec-serwalker/src/cluster/mod.rs +++ b/crates/flutterdec-serwalker/src/cluster/mod.rs @@ -559,3 +559,146 @@ impl Cluster for _StringCluster { Ok(self.end_of_fill - self.start_of_fill) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::UNSIGNED_M; + + fn leb_unsigned(mut value: u64) -> Vec { + let mut out = Vec::new(); + loop { + let low = (value & 0x7f) as u8; + let rest = value >> 7; + if rest == 0 && low <= (0xff - UNSIGNED_M) { + out.push(low + UNSIGNED_M); + return out; + } + out.push(low); + value = rest; + } + } + + /// StringDeserializationCluster writes `length << 1 | is_two_byte` in the + /// alloc section and again in the fill section, then the payload. + fn encode_string_cluster(strings: &[(&str, bool)]) -> Vec { + let encoded = |s: &str, two: bool| -> u64 { + let len = if two { + s.chars().map(|c| c.len_utf16()).sum() + } else { + s.chars().count() + }; + ((len as u64) << 1) | two as u64 + }; + let mut alloc = leb_unsigned(strings.len() as u64); + for (s, two) in strings { + alloc.extend_from_slice(&leb_unsigned(encoded(s, *two))); + } + let mut fill = Vec::new(); + for (s, two) in strings { + fill.extend_from_slice(&leb_unsigned(encoded(s, *two))); + if *two { + for u in s.encode_utf16() { + fill.extend_from_slice(&u.to_le_bytes()); + } + } else { + fill.extend(s.chars().map(|c| c as u32 as u8)); + } + } + alloc.extend(fill); + alloc + } + + fn read_strings(bytes: &[u8], count: usize) -> Vec { + let mut cluster = _StringCluster::default(); + let mut stream = Stream::new(bytes); + let mut next_ref = 1u64; + cluster.read_alloc(&mut next_ref, &mut stream).unwrap(); + cluster.read_fill(&mut stream).unwrap(); + assert_eq!( + next_ref, + 1 + count as u64, + "alloc must claim one ref per string" + ); + cluster + .objs + .iter() + .map(|o| o.internal_str.clone()) + .collect() + } + + #[test] + fn one_byte_strings_round_trip() { + let input = [ + ("main", false), + ("package:app/main.dart", false), + ("", false), + ]; + let bytes = encode_string_cluster(&input); + assert_eq!( + read_strings(&bytes, input.len()), + vec!["main", "package:app/main.dart", ""] + ); + } + + /// The payload is Latin-1, so bytes above 0x7F are ordinary characters. + /// Decoding as UTF-8 used to panic the whole parse here. + #[test] + fn one_byte_strings_carry_latin1_payloads() { + let input = [("caf\u{e9}", false), ("\u{ff}\u{80}", false)]; + let bytes = encode_string_cluster(&input); + assert_eq!( + read_strings(&bytes, input.len()), + vec!["caf\u{e9}", "\u{ff}\u{80}"] + ); + } + + #[test] + fn two_byte_strings_round_trip() { + let input = [("\u{4f60}\u{597d}", true), ("mixed \u{2713}", true)]; + let bytes = encode_string_cluster(&input); + assert_eq!( + read_strings(&bytes, input.len()), + vec!["\u{4f60}\u{597d}", "mixed \u{2713}"] + ); + } + + /// Both widths in one cluster: the per-object type comes from the low bit + /// of the encoded word, so mixing them catches a mis-shifted length. + #[test] + fn mixed_width_cluster_stays_in_sync() { + let input = [ + ("ascii", false), + ("\u{4f60}", true), + ("caf\u{e9}", false), + ("\u{2713}", true), + ]; + let bytes = encode_string_cluster(&input); + assert_eq!( + read_strings(&bytes, input.len()), + vec!["ascii", "\u{4f60}", "caf\u{e9}", "\u{2713}"] + ); + } + + /// Dart strings may hold unpaired surrogates, which are not valid UTF-16. + /// Those must decode lossily rather than panic. + #[test] + fn unpaired_surrogates_do_not_panic() { + let mut bytes = leb_unsigned(1); + bytes.extend_from_slice(&leb_unsigned((1 << 1) | 1)); // one two-byte unit + bytes.extend_from_slice(&leb_unsigned((1 << 1) | 1)); // repeated in the fill section + bytes.extend_from_slice(&0xd800u16.to_le_bytes()); // lone high surrogate + assert_eq!(read_strings(&bytes, 1), vec!["\u{fffd}"]); + } + + #[test] + fn a_truncated_payload_errors_instead_of_panicking() { + let mut bytes = encode_string_cluster(&[("hello", false)]); + bytes.truncate(bytes.len() - 3); + let mut cluster = _StringCluster::default(); + let mut stream = Stream::new(&bytes); + let mut next_ref = 1u64; + cluster.read_alloc(&mut next_ref, &mut stream).unwrap(); + assert!(cluster.read_fill(&mut stream).is_err()); + } +} diff --git a/crates/flutterdec-serwalker/src/lib.rs b/crates/flutterdec-serwalker/src/lib.rs index cb37d28..e90a82d 100644 --- a/crates/flutterdec-serwalker/src/lib.rs +++ b/crates/flutterdec-serwalker/src/lib.rs @@ -11,26 +11,12 @@ mod stream; use flutterdec_adapter::ProgramModel; -use crate::{ - info_producer::{enrich_model_headers, enrich_model_object_info}, - snapshot::parse_snapshot, - stream::Stream, -}; - +#[allow(dead_code)] fn walk_snapshot_and_enrich_model( - isolate_data: &[u8], - isolate_instr: &[u8], - vm_data: Option<&[u8]>, - vm_instr: Option<&[u8]>, -) -> ProgramModel { - let program_model: ProgramModel; - - let mut isolate_data_stream = Stream::new(isolate_data); - let isolate_data_snapshot = parse_snapshot(&mut isolate_data_stream)?; - - enrich_model_headers(&mut program_model, &isolate_data_snapshot); - enrich_model_object_info(&mut program_model, &isolate_data_snapshot); - enrich_model_object_pool(&mut program_model, &isolate_data_snapshot); - - program_model + _isolate_data: &[u8], + _isolate_instr: &[u8], + _vm_data: Option<&[u8]>, + _vm_instr: Option<&[u8]>, +) -> anyhow::Result { + todo!("wire parse_snapshot into the info_producer passes") } diff --git a/crates/flutterdec-serwalker/src/object_store/special_resolvers.rs b/crates/flutterdec-serwalker/src/object_store/special_resolvers.rs index 16e7d43..6948797 100644 --- a/crates/flutterdec-serwalker/src/object_store/special_resolvers.rs +++ b/crates/flutterdec-serwalker/src/object_store/special_resolvers.rs @@ -1,7 +1,6 @@ use flutterdec_adapter::LibraryInfo; +#[allow(dead_code)] pub fn resolve_root_library() -> anyhow::Result { - let root_lib: LibraryInfo; - - Ok(root_lib) + todo!("resolve the root library from the object store") } diff --git a/crates/flutterdec-serwalker/src/snapshot.rs b/crates/flutterdec-serwalker/src/snapshot.rs index 2d3d25a..f409688 100644 --- a/crates/flutterdec-serwalker/src/snapshot.rs +++ b/crates/flutterdec-serwalker/src/snapshot.rs @@ -38,8 +38,7 @@ impl TryFrom for SnapshotKind { pub struct DataSnapshot { clusters: HashMap>, cluster_order: Vec, // used in the fill step to know which cluster's read_fill function to call - roots: ProgramRoots, - + // roots: ProgramRoots, // TODO: define ProgramRoots in object_store magic_bytes: u32, size: u64, kind: SnapshotKind, @@ -151,3 +150,174 @@ pub fn parse_snapshot(stream: &mut Stream) -> anyhow::Result { Ok(snapshot) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{SIGNED_M, UNSIGNED_M}; + + fn leb(mut value: u64, marker: u8) -> Vec { + let mut out = Vec::new(); + loop { + let low = (value & 0x7f) as u8; + let rest = value >> 7; + if rest == 0 && low <= (0xff - marker) { + out.push(low + marker); + return out; + } + out.push(low); + value = rest; + } + } + + /// Snapshot header, snapshot.h:36. magic(u32) + length(i64) + kind(i64) as + /// raw little endian, then 32 version chars and a NUL terminated feature + /// string, then five LEB128 counts. + struct Header { + kind: u64, + version: String, + features: String, + num_base_objects: u64, + num_objects: u64, + num_clusters: u64, + } + + impl Default for Header { + fn default() -> Self { + Self { + kind: 3, // kFullAOT + version: "b3d0d9d1c1e8b57e9b31f8e0e4a5c7d2".into(), + features: "product no-code_comments arm64".into(), + num_base_objects: 42, + num_objects: 1000, + num_clusters: 7, + } + } + } + + impl Header { + fn encode(&self, magic: u32) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(&magic.to_le_bytes()); + b.extend_from_slice(&0u64.to_le_bytes()); // length, unused by the parser + b.extend_from_slice(&self.kind.to_le_bytes()); + b.extend_from_slice(self.version.as_bytes()); + b.extend_from_slice(self.features.as_bytes()); + b.push(0); + for v in [ + self.num_base_objects, + self.num_objects, + self.num_clusters, + 0, // instructions table length + 0, // instructions table offset + ] { + b.extend_from_slice(&leb(v, UNSIGNED_M)); + } + b + } + } + + fn parse(bytes: &[u8]) -> anyhow::Result { + let mut snap = DataSnapshot::default(); + let mut stream = Stream::new(bytes); + snap.parse_header(&mut stream)?; + Ok(snap) + } + + #[test] + fn parses_a_well_formed_header() { + let h = Header::default(); + let snap = parse(&h.encode(MAGIC_BYTES)).unwrap(); + assert_eq!(snap.magic_bytes, MAGIC_BYTES); + assert_eq!(snap.num_base_objects, 42); + assert_eq!(snap.num_objects, 1000); + assert_eq!(snap.num_clusters, 7); + } + + /// The version hash is 32 raw chars with no terminator and the features + /// string runs to the NUL, so the split is positional. Getting the length + /// wrong silently corrupts both fields rather than failing. + #[test] + fn splits_version_from_features_at_thirty_two_chars() { + let h = Header::default(); + let snap = parse(&h.encode(MAGIC_BYTES)).unwrap(); + assert_eq!(snap.version_hash, h.version); + assert_eq!( + snap.version_hash.len(), + crate::constants::VERSION_HASH_LENGTH + ); + assert_eq!(snap.features, h.features); + } + + #[test] + fn rejects_a_bad_magic() { + let h = Header::default(); + assert!(parse(&h.encode(0xdeadbeef)).is_err()); + } + + #[test] + fn rejects_an_out_of_range_snapshot_kind() { + let h = Header { + kind: 99, + ..Default::default() + }; + assert!(parse(&h.encode(MAGIC_BYTES)).is_err()); + } + + /// kFull=0, kFullCore=1, kFullJIT=2, kFullAOT=3, kNone=4, kInvalid=5. + /// There is no kModule, so 5 must be the last valid value. + #[test] + fn snapshot_kind_numbering_matches_dart() { + for k in 0..=5u64 { + assert!( + SnapshotKind::try_from(k).is_ok(), + "kind {k} should be valid" + ); + } + assert!( + SnapshotKind::try_from(6).is_err(), + "there is no seventh kind" + ); + assert!(matches!( + SnapshotKind::try_from(3), + Ok(SnapshotKind::FullAOT) + )); + assert!(matches!(SnapshotKind::try_from(4), Ok(SnapshotKind::None))); + } + + /// The counts are ReadUnsigned (0x80), not Read (0xC0). Encoding them with + /// the other marker must not silently produce plausible numbers. + #[test] + fn header_counts_use_the_unsigned_marker() { + let h = Header { + num_objects: 1000, + ..Default::default() + }; + let good = parse(&h.encode(MAGIC_BYTES)).unwrap(); + assert_eq!(good.num_objects, 1000); + + // Re-encode just the counts with the signed marker. + let mut b = h.encode(MAGIC_BYTES); + let prefix = 20 + h.version.len() + h.features.len() + 1; + b.truncate(prefix); + for v in [h.num_base_objects, h.num_objects, h.num_clusters, 0, 0] { + b.extend_from_slice(&leb(v, SIGNED_M)); + } + let wrong = parse(&b).unwrap(); + assert_ne!( + wrong.num_objects, 1000, + "wrong marker must not decode cleanly" + ); + } + + #[test] + fn truncated_headers_error_instead_of_panicking() { + let full = Header::default().encode(MAGIC_BYTES); + for cut in [0, 4, 12, 20, 30, full.len() - 1] { + assert!( + parse(&full[..cut]).is_err(), + "truncation at {cut} should error" + ); + } + } +} diff --git a/crates/flutterdec-serwalker/src/stream.rs b/crates/flutterdec-serwalker/src/stream.rs index 8e8a8ff..dcd8338 100644 --- a/crates/flutterdec-serwalker/src/stream.rs +++ b/crates/flutterdec-serwalker/src/stream.rs @@ -2,13 +2,13 @@ use anyhow::{anyhow, bail, Result}; use crate::constants::{DATA_BITS_PER_BYTE, SIGNED_M, UNSIGNED_M, UNSIGNED_MAX_DATA_PER_BYTE}; -pub struct Stream { - byte_stream: &[u8], +pub struct Stream<'a> { + byte_stream: &'a [u8], curr_stream_offset: usize, } -impl Stream { - pub fn new(byte_stream: &[u8]) -> Self { +impl<'a> Stream<'a> { + pub fn new(byte_stream: &'a [u8]) -> Self { Self { byte_stream, curr_stream_offset: 0, @@ -144,3 +144,250 @@ impl Stream { self.take(len) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Dart's WriteStream, mirrored: 7-bit little endian groups, every byte but + /// the last has its MSb clear, the last carries `marker + remaining_bits`. + /// datastream.h:231 + fn encode_leb128(mut value: u64, marker: u8) -> Vec { + let mut out = Vec::new(); + loop { + let low = (value & 0x7f) as u8; + let rest = value >> 7; + // The last group is the one that still fits under the marker. + if rest == 0 && low <= (0xff - marker) { + out.push(low + marker); + return out; + } + out.push(low); + value = rest; + } + } + + /// Dart's ReadRefId is big endian, seven bits per byte, terminator has the + /// MSb set, and the reader adds 128 back. datastream.h:103 + fn encode_ref_id(value: u32) -> Vec { + let mut groups = Vec::new(); + let mut v = value; + loop { + groups.push((v & 0x7f) as u8); + v >>= 7; + if v == 0 { + break; + } + } + groups.reverse(); + let last = groups.len() - 1; + groups[last] |= 0x80; + groups + } + + #[test] + fn read_unsigned_round_trips() { + for v in [ + 0u64, + 1, + 63, + 64, + 127, + 128, + 255, + 0x7000, + 0xffff, + u32::MAX as u64, + ] { + let bytes = encode_leb128(v, UNSIGNED_M); + assert_eq!( + Stream::new(&bytes).read_unsigned().unwrap(), + v, + "unsigned round trip failed for {v:#x} encoded as {bytes:02x?}" + ); + } + } + + #[test] + fn read_signed_round_trips() { + for v in [0u64, 1, 63, 0x7000, 0xffff] { + let bytes = encode_leb128(v, SIGNED_M); + assert_eq!( + Stream::new(&bytes).read().unwrap(), + v, + "signed round trip failed for {v:#x} encoded as {bytes:02x?}" + ); + } + } + + /// The distinction that produced real bugs: `Read` uses kEndByteMarker + /// (0xC0) and `ReadUnsigned` uses kEndUnsignedByteMarker (0x80). Decoding + /// with the wrong one is silently off by 64 in the final group, so this test + /// fails the moment the two are swapped. + #[test] + fn the_two_markers_are_not_interchangeable() { + // cid 7 (Function) shifted into ClassIdTag position. + let tags = 0x7000u64; + let signed = encode_leb128(tags, SIGNED_M); + let unsigned = encode_leb128(tags, UNSIGNED_M); + assert_ne!(signed, unsigned, "the two encodings must differ"); + + assert_eq!(Stream::new(&signed).read().unwrap(), tags); + assert_eq!(Stream::new(&unsigned).read_unsigned().unwrap(), tags); + + // Cross-decoding is wrong, and wrong by the marker delta in the top group. + assert_ne!(Stream::new(&signed).read_unsigned().unwrap(), tags); + assert_ne!(Stream::new(&unsigned).read().unwrap(), tags); + } + + /// Negative values come back as the two's complement bit pattern, so a + /// narrowing cast at the call site recovers the original. + #[test] + fn signed_reads_sign_extend() { + assert_eq!(Stream::new(&[0xbf]).read().unwrap() as i64, -1); + assert_eq!(Stream::new(&[0xc0]).read().unwrap() as i64, 0); + assert_eq!(Stream::new(&[0x80]).read().unwrap() as i64, -64); + assert_eq!(Stream::new(&[0x80]).read().unwrap() as i8, -64); + } + + #[test] + fn read_ref_id_round_trips() { + for v in [0u32, 1, 127, 128, 129, 1000, 41337, (1 << 28) - 1] { + let bytes = encode_ref_id(v); + assert_eq!( + Stream::new(&bytes).read_ref_id().unwrap(), + v, + "ref id round trip failed for {v} encoded as {bytes:02x?}" + ); + } + } + + /// Ref ids are big endian while everything else is little endian. If someone + /// "unifies" them onto the LEB128 decoder this catches it: the two encodings + /// of 128 differ, and each decoder rejects the other's bytes. + #[test] + fn ref_ids_are_big_endian_not_leb128() { + let as_ref = encode_ref_id(128); + let as_leb = encode_leb128(128, UNSIGNED_M); + assert_eq!(as_ref, vec![0x01, 0x80]); + assert_eq!(as_leb, vec![0x00, 0x81]); + assert_eq!(Stream::new(&as_ref).read_ref_id().unwrap(), 128); + assert_ne!(Stream::new(&as_leb).read_ref_id().unwrap(), 128); + } + + /// Dart unrolls exactly five stages and asserts past that ("256MB is enough + /// for anyone"). The input below terminates properly, so only the stage cap + /// can reject it: eight continuation bytes then a terminator. + #[test] + fn ref_id_is_bounded_to_five_stages() { + let mut overlong = vec![0x01u8; 8]; + overlong.push(0x80); + assert!(Stream::new(&overlong).read_ref_id().is_err()); + // Five stages is still accepted. + let ok = [0x01u8, 0x01, 0x01, 0x01, 0x80]; + assert!(Stream::new(&ok).read_ref_id().is_ok()); + // And truncation is caught too. + assert!(Stream::new(&[0x01; 3]).read_ref_id().is_err()); + } + + /// `Deserializer::Read` with sizeof(T) == 1 is ReadStream::ReadByte, a + /// plain `*current_++`, not LEB128. A byte under 0x80 is a complete value + /// for read_byte and a continuation byte for the LEB decoder, which is + /// exactly how the byte-sized cluster fields desynced. + #[test] + fn read_byte_is_raw_not_leb128() { + assert_eq!(Stream::new(&[0x03, 0x07]).read_byte().unwrap(), 3); + + let mut raw = Stream::new(&[0x03, 0x07]); + assert_eq!(raw.read_byte().unwrap(), 3); + assert_eq!( + raw.get_current_pos(), + 1, + "read_byte consumes exactly one byte" + ); + + let mut leb = Stream::new(&[0x03, 0x07]); + let _ = leb.read(); + assert!( + leb.get_current_pos() > 1, + "the LEB decoder keeps going past 0x03" + ); + } + + #[test] + fn raw_fixed_width_reads_are_little_endian() { + assert_eq!( + Stream::new(&[0xf5, 0xf5, 0xdc, 0xdc]) + .read_raw_u32() + .unwrap(), + crate::constants::MAGIC_BYTES + ); + assert_eq!( + Stream::new(&[1, 0, 0, 0, 0, 0, 0, 0]) + .read_raw_u64() + .unwrap(), + 1 + ); + } + + /// OneByteString is Latin-1: every byte maps to the code point of the same + /// value. from_utf8 would reject 0xE9 on its own. + #[test] + fn latin1_accepts_the_high_half() { + assert_eq!(Stream::new(&[0xe9]).read_latin1(1).unwrap(), "\u{e9}"); + assert_eq!( + Stream::new(&[0x41, 0xff]).read_latin1(2).unwrap(), + "A\u{ff}" + ); + let all: Vec = (0u8..=255).collect(); + let decoded = Stream::new(&all).read_latin1(256).unwrap(); + assert_eq!(decoded.chars().count(), 256); + assert_eq!(decoded.chars().last(), Some('\u{ff}')); + } + + #[test] + fn c_strings_stop_at_the_nul_and_consume_it() { + let mut s = Stream::new(b"abc\0rest"); + assert_eq!(s.read_c_string().unwrap(), "abc"); + assert_eq!(s.get_current_pos(), 4); + } + + /// Every read is bounds checked. We parse blobs pulled out of third party + /// APKs, so a truncated snapshot has to be an error, never a panic. + #[test] + fn truncated_input_errors_instead_of_panicking() { + assert!(Stream::new(&[]).read_byte().is_err()); + assert!( + Stream::new(&[0x00, 0x00]).read_unsigned().is_err(), + "no terminator" + ); + assert!(Stream::new(&[0x41]).read_c_string().is_err(), "no nul"); + assert!(Stream::new(&[0x00, 0x00]).read_raw_u32().is_err()); + assert!(Stream::new(&[1, 2, 3]).read_latin1(4).is_err()); + assert!(Stream::new(&[1, 2, 3]).read_bytes(4).is_err()); + assert!( + Stream::new(&[0x01; 12]).read_unsigned().is_err(), + "overlong LEB" + ); + } + + #[test] + fn seek_is_bounds_checked_and_position_tracks_reads() { + let mut s = Stream::new(&[0u8; 8]); + assert!(s.seek(8).is_ok(), "seeking to the end is valid"); + assert!(s.seek(9).is_err()); + s.seek(0).unwrap(); + s.read_raw_u32().unwrap(); + assert_eq!(s.get_current_pos(), 4); + } + + #[test] + fn zero_copy_and_copying_byte_reads_agree() { + let data = [1u8, 2, 3, 4]; + assert_eq!(Stream::new(&data).read_bytes(3).unwrap(), vec![1, 2, 3]); + assert_eq!( + Stream::new(&data).read_bytes_zero_copy(3).unwrap(), + &data[..3] + ); + } +} diff --git a/crates/flutterdec-serwalker/src/utils.rs b/crates/flutterdec-serwalker/src/utils.rs index 0481581..1eef041 100644 --- a/crates/flutterdec-serwalker/src/utils.rs +++ b/crates/flutterdec-serwalker/src/utils.rs @@ -3,6 +3,7 @@ use crate::constants::ClassId; #[macro_export] macro_rules! DECLARE_FIXED_LENGTH_CLUSTER { ($name:ident, $cluster_name:ident, |$_self:ident, $stream:ident| $fill_impl:block) => { + #[derive(Default)] pub struct $cluster_name { tags: u32, cid: ClassId, @@ -63,6 +64,7 @@ macro_rules! DECLARE_FIXED_LENGTH_CLUSTER { #[macro_export] macro_rules! DECLARE_VARIABLE_LENGTH_CLUSTER { ($name:ident, $cluster_name:ident) => { + #[derive(Default)] pub struct $cluster_name { tags: u32, cid: ClassId, @@ -140,3 +142,80 @@ pub fn decode_tags(tags: u32) -> anyhow::Result { DECODE_IS_CANONICAL!(tags), )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::ClassId; + + /// UntaggedObject::TagBits, raw_object.h: ClassIdTag at bit 12 and 20 wide, + /// CanonicalBit at 1, ImmutableBit at 7. + fn encode_tags(cid: u32, canonical: bool, immutable: bool) -> u32 { + (cid << 12) | ((immutable as u32) << 7) | ((canonical as u32) << 1) + } + + #[test] + fn decodes_the_three_header_fields_independently() { + for (cid, canonical, immutable) in [ + (ClassId::FunctionCid, false, false), + (ClassId::LibraryCid, true, false), + (ClassId::_StringCid, false, true), + (ClassId::ClassCid, true, true), + ] { + let d = decode_tags(encode_tags(cid as u32, canonical, immutable)).unwrap(); + assert_eq!(d.get_cid(), cid); + assert_eq!(d.is_canonical(), canonical, "canonical bit for {cid:?}"); + assert_eq!( + d.is_deeply_immutable(), + immutable, + "immutable bit for {cid:?}" + ); + } + } + + /// The bits must not bleed into each other. Setting only one at a time + /// catches an off-by-one in any of the three shifts. + #[test] + fn the_flag_bits_do_not_overlap() { + let only_canonical = + decode_tags(encode_tags(ClassId::FunctionCid as u32, true, false)).unwrap(); + assert!(only_canonical.is_canonical() && !only_canonical.is_deeply_immutable()); + + let only_immutable = + decode_tags(encode_tags(ClassId::FunctionCid as u32, false, true)).unwrap(); + assert!(!only_immutable.is_canonical() && only_immutable.is_deeply_immutable()); + + // Neither flag may disturb the class id. + for (c, i) in [(false, false), (true, false), (false, true), (true, true)] { + let d = decode_tags(encode_tags(ClassId::LibraryCid as u32, c, i)).unwrap(); + assert_eq!(d.get_cid(), ClassId::LibraryCid); + } + } + + /// A cid we do not know almost always means the stream desynced upstream, + /// so it has to surface rather than defaulting to IllegalCid. + #[test] + fn an_unknown_class_id_is_an_error() { + let bogus = encode_tags(0xfffff, false, false); + match decode_tags(bogus) { + Ok(_) => panic!("an unknown class id must not decode"), + Err(e) => assert!( + e.to_string().contains("unknown class id"), + "unexpected message: {e}" + ), + } + } + + /// Real headers observed on the wire: the class id occupies bits 12..32, so + /// a two-byte cid still round trips. + #[test] + fn wide_class_ids_survive_the_shift() { + let d = decode_tags(encode_tags( + ClassId::NumPredefinedCids as u32 - 1, + false, + false, + )) + .unwrap(); + assert_eq!(d.get_cid() as u32, ClassId::NumPredefinedCids as u32 - 1); + } +}