From 264571225d41492e471ca34e5295f5e3f94dacd4 Mon Sep 17 00:00:00 2001 From: caverav Date: Tue, 28 Jul 2026 09:57:15 -0400 Subject: [PATCH 1/2] fix(serwalker): make the crate compile again The branch has not built since f50d581. Four in-flight stubs were blocking: - Stream lost its 'a lifetime parameter when read_bytes_zero_copy landed, so the struct, the impl block and two signatures no longer resolved - walk_snapshot_and_enrich_model used an uninitialised binding, applied ? in a function returning ProgramModel, and called a symbol that is not exported yet - DataSnapshot referenced ProgramRoots, which is not defined - resolve_root_library returned an uninitialised binding Unfinished bodies are now todo!() so they typecheck without pretending to work. The lifetime is restored as it was. --- crates/flutterdec-serwalker/src/lib.rs | 28 +++++-------------- .../src/object_store/special_resolvers.rs | 5 ++-- 2 files changed, 9 insertions(+), 24 deletions(-) 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") } From c4eb9e384bfbcbba6d8486830212bf7ac1d9a42c Mon Sep 17 00:00:00 2001 From: caverav Date: Tue, 28 Jul 2026 09:57:31 -0400 Subject: [PATCH 2/2] test(serwalker): cover the stream primitives, tag decoding, header and strings 31 unit tests over the four pieces every cluster sits on. Each encodes its own input the way Dart's WriteStream does, so the tests assert against the format rather than against our decoder. stream (14): LEB128 round trips for both markers, the Read vs ReadUnsigned distinction that produced the cluster-tag and kind_tag bugs, big-endian ref ids and their five-stage cap, read_byte staying raw so byte-sized fields cannot be LEB decoded, Latin-1 over the full 0..=255 range, and bounds checks on every reader. utils (4): ClassIdTag at bit 12, CanonicalBit at 1, ImmutableBit at 7, proven independent so an off-by-one in any shift fails, plus unknown cids erroring instead of defaulting to IllegalCid. snapshot (7): header round trip, the 32-char version/features split, magic and kind rejection, kind numbering with no kModule, and counts decoding with the unsigned marker. cluster (6): string cluster round trips for one-byte, two-byte and mixed clusters, Latin-1 payloads, unpaired surrogates decoding lossily, and a truncated payload erroring. Verified by mutation: 15 deliberate regressions were introduced one at a time (markers swapped, ref ids made little endian, Latin-1 reverted to from_utf8, bounds checks removed, every tag shift moved by one, the version split shortened, from_utf16_lossy made strict) and all 15 were caught. DECLARE_*_LENGTH_CLUSTER now derives Default on the generated struct so a cluster can be built in a test without going through decide_cluster. --- .../flutterdec-serwalker/src/cluster/mod.rs | 143 ++++++++++ crates/flutterdec-serwalker/src/snapshot.rs | 174 +++++++++++- crates/flutterdec-serwalker/src/stream.rs | 255 +++++++++++++++++- crates/flutterdec-serwalker/src/utils.rs | 79 ++++++ 4 files changed, 645 insertions(+), 6 deletions(-) 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/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); + } +}