Skip to main content

blob_decoder/
v8_value.rs

1//! V8 / Blink structured-clone **value deserializer**.
2//!
3//! When Chromium persists a JavaScript value — an IndexedDB record, a
4//! `postMessage` payload, a Service Worker cache entry — it serializes it with
5//! V8's `ValueSerializer` (the *structured clone* wire format), optionally
6//! wrapped in Blink's `SerializedScriptValue` envelope. This module decodes those
7//! bytes back into a structured [`V8Value`], so an opaque IndexedDB blob unwraps
8//! the way `blob-decoder` already unwraps bplist / gzip / protobuf.
9//!
10//! # Wire format
11//!
12//! A V8 stream opens with a version header (`0xFF` + an LEB128 version) and is
13//! then a sequence of one-byte **serialization tags**, each introducing a typed
14//! value; lengths are unsigned LEB128 varints and `kInt32` is zig-zag encoded.
15//! Blink prepends its own `0xFF <blink-version>` and an optional `0xFE` trailer
16//! (an 8-byte BE offset + 4-byte BE size) before the nested V8 payload.
17//!
18//! Authoritative references (decode logic below is a clean-room Rust
19//! reimplementation, **not** a port of the C++):
20//! - V8 `src/objects/value-serializer.cc` — the `SerializationTag` enum, varint
21//!   framing, the `0xFF` version header.
22//! - Blink `.../serialization/serialization_tag.h` — the `0xFF`/`0xFE` envelope.
23//!
24//! The canonical tag→name tables live in the fleet knowledge crate
25//! `forensicnomicon-core::v8_serialization` (module present in the source tree,
26//! not yet on crates.io as of forensicnomicon-core 1.4.0). The individual tag
27//! *byte* constants a decoder must match on are mirrored here from that source;
28//! migrate to the published constants once that module ships to the registry.
29//!
30//! # Safety
31//!
32//! All input is attacker-controllable. The invariant is: **never panic, never
33//! read out of bounds, never trust a length field, never OOM.** Every read is
34//! bounds-checked (returns [`V8Error`], never indexes blindly), recursion is
35//! depth-capped, and total materialized nodes are budget-capped so a crafted blob
36//! (deep nesting, huge sparse-array length, reference amplification) fails loud
37//! instead of exhausting memory or the stack.
38
39/// A decoded V8 / Blink structured-clone value.
40///
41/// Numeric JS values split by their wire encoding: [`V8Value::Int`] for the
42/// zig-zag `kInt32` / `kUint32` tags, [`V8Value::Double`] for `kDouble` (which V8
43/// also uses for any integer outside `i32` range). `BigInt` is rendered to a
44/// decimal string. Boxed primitives (`new Number(7)`, `new String('x')`,
45/// `new Boolean(true)`) keep their wrapper identity so the reading is faithful.
46#[derive(Debug, Clone, PartialEq, serde::Serialize)]
47#[serde(tag = "type", content = "value", rename_all = "snake_case")]
48pub enum V8Value {
49    /// `kUndefined` (`_`).
50    Undefined,
51    /// `kNull` (`0`).
52    Null,
53    /// `kTheHole` (`-`) — an absent element in a sparse/holey array.
54    Hole,
55    /// `kTrue` / `kFalse`.
56    Bool(bool),
57    /// `kInt32` (zig-zag) or `kUint32`.
58    Int(i64),
59    /// `kDouble` — an IEEE-754 double (also used for integers outside `i32`).
60    Double(f64),
61    /// `kBigInt`, rendered to its decimal string (e.g. `"-42"`).
62    BigInt(String),
63    /// `kUtf8String` / `kOneByteString` (Latin-1) / `kTwoByteString` (UTF-16LE).
64    String(String),
65    /// `kDate` — milliseconds since the Unix epoch.
66    Date(f64),
67    /// `kRegExp` — the source pattern plus V8's raw flag bitset.
68    RegExp {
69        /// The regexp source (without delimiters).
70        source: String,
71        /// V8's raw flag bits (`global=1, ignoreCase=2, multiline=4, …`).
72        flags: u32,
73    },
74    /// `kBeginDenseJSArray` / `kBeginSparseJSArray` — holes are [`V8Value::Hole`].
75    Array(Vec<V8Value>),
76    /// `kBeginJSObject` — properties in serialized (insertion) order.
77    Object(Vec<(String, V8Value)>),
78    /// `kBeginJSMap` — key/value pairs in insertion order.
79    Map(Vec<(V8Value, V8Value)>),
80    /// `kBeginJSSet` — members in insertion order.
81    Set(Vec<V8Value>),
82    /// `kArrayBuffer` — the raw bytes.
83    ArrayBuffer(Vec<u8>),
84    /// `kNumberObject` — `new Number(x)`.
85    NumberObject(f64),
86    /// `kStringObject` — `new String(x)`.
87    StringObject(String),
88    /// `kTrueObject` / `kFalseObject` — `new Boolean(x)`.
89    BooleanObject(bool),
90    /// `kBigIntObject` — `Object(x)` of a BigInt, decimal string.
91    BigIntObject(String),
92}
93
94/// A decode failure. Every arm names *what* failed and *where* (byte offset), and
95/// surfaces the offending value (tag byte / id) so an analyst can identify it —
96/// an "unknown" is never reported without the bytes that were actually there.
97#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
98pub enum V8Error {
99    /// Fewer bytes remained than the value required.
100    #[error("truncated at offset {offset}: needed {needed} more byte(s), {available} available")]
101    Truncated {
102        offset: usize,
103        needed: usize,
104        available: usize,
105    },
106    /// The stream did not open with the `0xFF` version tag.
107    #[error("missing V8 version header at offset {offset}: expected 0xFF, found 0x{found:02x}")]
108    BadVersion { offset: usize, found: u8 },
109    /// A tag byte is not a value-introducing V8 serialization tag we decode.
110    #[error("unsupported V8 serialization tag 0x{tag:02x} ({tag:?} as char) at offset {offset}")]
111    UnsupportedTag { offset: usize, tag: u8 },
112    /// A `kHostObject` (`\\`) embedder value (Blink DOM type or a node typed-array
113    /// delegate). Surfaced with the following Blink tag byte, not fabricated.
114    #[error(
115        "unsupported host/embedder object at offset {offset} (kHostObject, Blink tag 0x{blink_tag:02x})"
116    )]
117    HostObject { offset: usize, blink_tag: u8 },
118    /// A `kObjectReference` pointed at an id that was never assigned.
119    #[error("dangling object reference to id {id} at offset {offset}")]
120    BadReference { offset: usize, id: u64 },
121    /// A varint ran past 10 bytes / would overflow `u64`.
122    #[error("malformed varint at offset {offset}")]
123    BadVarint { offset: usize },
124    /// A `kTwoByteString` had an odd byte length (not whole UTF-16 units).
125    #[error("odd-length UTF-16 string ({len} bytes) at offset {offset}")]
126    OddUtf16 { offset: usize, len: usize },
127    /// An object/map key was neither a string nor an integer.
128    #[error("unsupported property key type at offset {offset}")]
129    BadKey { offset: usize },
130    /// Recursion depth exceeded [`V8Limits::max_depth`] (DoS guard).
131    #[error("recursion depth cap ({cap}) exceeded at offset {offset}")]
132    DepthCap { offset: usize, cap: usize },
133    /// Total materialized nodes exceeded [`V8Limits::max_nodes`] (DoS guard).
134    #[error("value/node cap ({cap}) exceeded")]
135    NodeCap { cap: usize },
136    /// A declared length exceeded the node cap (e.g. a huge sparse-array length or
137    /// bigint digit count) — rejected before allocating.
138    #[error("declared length {len} exceeds cap {cap} at offset {offset}")]
139    LengthCap { offset: usize, len: u64, cap: usize },
140}
141
142/// Resource bounds for the deserializer — the guard against stack overflow,
143/// unbounded allocation, and reference-amplification on untrusted input.
144#[derive(Debug, Clone, Copy)]
145pub struct V8Limits {
146    /// Maximum nesting depth of arrays/objects/maps/sets.
147    pub max_depth: usize,
148    /// Maximum total materialized values (fresh reads **and** reference clones).
149    pub max_nodes: usize,
150}
151
152impl Default for V8Limits {
153    fn default() -> Self {
154        Self {
155            max_depth: 256,
156            max_nodes: 4_000_000,
157        }
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Serialization tag bytes.
163//
164// Mirrored from forensicnomicon-core::v8_serialization (the canonical tag table);
165// only the value-introducing tags this decoder implements are named here.
166// ---------------------------------------------------------------------------
167
168const TAG_VERSION: u8 = 0xFF;
169const TAG_THE_HOLE: u8 = b'-';
170const TAG_UNDEFINED: u8 = b'_';
171const TAG_NULL: u8 = b'0';
172const TAG_TRUE: u8 = b'T';
173const TAG_FALSE: u8 = b'F';
174const TAG_INT32: u8 = b'I';
175const TAG_UINT32: u8 = b'U';
176const TAG_DOUBLE: u8 = b'N';
177const TAG_BIGINT: u8 = b'Z';
178const TAG_UTF8_STRING: u8 = b'S';
179const TAG_ONE_BYTE_STRING: u8 = b'"';
180const TAG_TWO_BYTE_STRING: u8 = b'c';
181const TAG_OBJECT_REFERENCE: u8 = b'^';
182const TAG_BEGIN_OBJECT: u8 = b'o';
183const TAG_END_OBJECT: u8 = b'{';
184const TAG_BEGIN_SPARSE_ARRAY: u8 = b'a';
185const TAG_END_SPARSE_ARRAY: u8 = b'@';
186const TAG_BEGIN_DENSE_ARRAY: u8 = b'A';
187const TAG_END_DENSE_ARRAY: u8 = b'$';
188const TAG_DATE: u8 = b'D';
189const TAG_TRUE_OBJECT: u8 = b'y';
190const TAG_FALSE_OBJECT: u8 = b'x';
191const TAG_NUMBER_OBJECT: u8 = b'n';
192const TAG_BIGINT_OBJECT: u8 = b'z';
193const TAG_STRING_OBJECT: u8 = b's';
194const TAG_REGEXP: u8 = b'R';
195const TAG_BEGIN_MAP: u8 = b';';
196const TAG_END_MAP: u8 = b':';
197const TAG_BEGIN_SET: u8 = b'\'';
198const TAG_END_SET: u8 = b',';
199const TAG_ARRAY_BUFFER: u8 = b'B';
200const TAG_HOST_OBJECT: u8 = b'\\';
201
202/// Blink `kTrailerOffsetTag` — introduces an 8-byte BE offset + 4-byte BE size.
203const BLINK_TRAILER_OFFSET: u8 = 0xFE;
204
205// ---------------------------------------------------------------------------
206// Public entry points
207// ---------------------------------------------------------------------------
208
209/// Deserialize a raw V8 `ValueSerializer` stream (`0xFF <version> <value>`) with
210/// default [`V8Limits`].
211///
212/// # Errors
213/// Returns [`V8Error`] on a truncated, malformed, or unsupported (host-object)
214/// stream — never panics.
215pub fn deserialize(bytes: &[u8]) -> Result<V8Value, V8Error> {
216    deserialize_with_limits(bytes, V8Limits::default())
217}
218
219/// Deserialize a raw V8 stream with explicit [`V8Limits`].
220///
221/// # Errors
222/// See [`deserialize`].
223pub fn deserialize_with_limits(bytes: &[u8], limits: V8Limits) -> Result<V8Value, V8Error> {
224    let mut r = Reader::new(bytes, limits);
225    r.read_version_header()?;
226    r.read_value(0)
227}
228
229/// Deserialize a Blink `SerializedScriptValue` (the on-disk IndexedDB form): the
230/// Blink `0xFF <version>` envelope, an optional `0xFE` trailer, then the nested
231/// V8 payload. Falls back to a raw V8 read when no Blink envelope is present.
232///
233/// # Errors
234/// See [`deserialize`].
235pub fn deserialize_blink(bytes: &[u8]) -> Result<V8Value, V8Error> {
236    deserialize_blink_with_limits(bytes, V8Limits::default())
237}
238
239/// Deserialize a Blink `SerializedScriptValue` with explicit [`V8Limits`].
240///
241/// # Errors
242/// See [`deserialize`].
243pub fn deserialize_blink_with_limits(bytes: &[u8], limits: V8Limits) -> Result<V8Value, V8Error> {
244    let mut r = Reader::new(bytes, limits);
245    // Blink envelope: 0xFF <blink-version>. If absent, treat the whole buffer as a
246    // raw V8 stream (some extraction paths hand back the inner payload directly).
247    if r.peek() == Some(TAG_VERSION) {
248        r.pos += 1;
249        let _blink_version = r.read_varint()?;
250        // Consume envelope framing tags (currently only the trailer offset) until
251        // the nested V8 version header (`0xFF`) begins.
252        while r.peek() == Some(BLINK_TRAILER_OFFSET) {
253            r.pos += 1;
254            // 8-byte BE offset + 4-byte BE size.
255            r.take(12)?;
256        }
257    }
258    r.read_version_header()?;
259    r.read_value(0)
260}
261
262/// True when `tag` opens a value we recognise — used by the identifier to decide
263/// whether a `0xFF`-led blob is plausibly V8 before reporting a failed decode.
264#[must_use]
265pub fn is_value_tag(tag: u8) -> bool {
266    matches!(
267        tag,
268        TAG_THE_HOLE
269            | TAG_UNDEFINED
270            | TAG_NULL
271            | TAG_TRUE
272            | TAG_FALSE
273            | TAG_INT32
274            | TAG_UINT32
275            | TAG_DOUBLE
276            | TAG_BIGINT
277            | TAG_UTF8_STRING
278            | TAG_ONE_BYTE_STRING
279            | TAG_TWO_BYTE_STRING
280            | TAG_OBJECT_REFERENCE
281            | TAG_BEGIN_OBJECT
282            | TAG_BEGIN_SPARSE_ARRAY
283            | TAG_BEGIN_DENSE_ARRAY
284            | TAG_DATE
285            | TAG_TRUE_OBJECT
286            | TAG_FALSE_OBJECT
287            | TAG_NUMBER_OBJECT
288            | TAG_BIGINT_OBJECT
289            | TAG_STRING_OBJECT
290            | TAG_REGEXP
291            | TAG_BEGIN_MAP
292            | TAG_BEGIN_SET
293            | TAG_ARRAY_BUFFER
294            | TAG_HOST_OBJECT
295    )
296}
297
298impl V8Value {
299    /// A short, bounded, human summary of this value's shape — for the
300    /// identifier's candidate summary line.
301    #[must_use]
302    pub fn summary(&self) -> String {
303        match self {
304            Self::Undefined => "undefined".to_owned(),
305            Self::Null => "null".to_owned(),
306            Self::Hole => "hole".to_owned(),
307            Self::Bool(b) => format!("boolean {b}"),
308            Self::Int(i) => format!("integer {i}"),
309            Self::Double(d) => format!("number {d}"),
310            Self::BigInt(s) => format!("bigint {s}n"),
311            Self::String(s) => format!("string {:?}", ellipsize(s)),
312            Self::Date(ms) => format!("date ({ms} ms)"),
313            Self::RegExp { source, flags } => {
314                format!("regexp /{}/ (flags {flags})", ellipsize(source))
315            }
316            Self::Array(v) => format!("array ({} element{})", v.len(), plural(v.len())),
317            Self::Object(kv) => format!("object ({} key{})", kv.len(), plural(kv.len())),
318            Self::Map(kv) => format!(
319                "map ({} entr{})",
320                kv.len(),
321                if kv.len() == 1 { "y" } else { "ies" }
322            ),
323            Self::Set(v) => format!("set ({} member{})", v.len(), plural(v.len())),
324            Self::ArrayBuffer(b) => format!("arraybuffer ({} byte{})", b.len(), plural(b.len())),
325            Self::NumberObject(d) => format!("Number({d})"),
326            Self::StringObject(s) => format!("String({:?})", ellipsize(s)),
327            Self::BooleanObject(b) => format!("Boolean({b})"),
328            Self::BigIntObject(s) => format!("BigInt({s})"),
329        }
330    }
331}
332
333fn plural(n: usize) -> &'static str {
334    if n == 1 {
335        ""
336    } else {
337        "s"
338    }
339}
340
341fn ellipsize(s: &str) -> String {
342    const MAX: usize = 32;
343    if s.chars().count() <= MAX {
344        s.to_owned()
345    } else {
346        let head: String = s.chars().take(MAX).collect();
347        format!("{head}…")
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Reader — bounds-checked, panic-free cursor
353// ---------------------------------------------------------------------------
354
355struct Reader<'a> {
356    data: &'a [u8],
357    pos: usize,
358    limits: V8Limits,
359    /// Objects assigned an id, in V8 assignment order. `None` = reserved but not
360    /// yet filled (an in-progress object; a reference to it before completion is a
361    /// cyclic structure we decline rather than loop on).
362    id_map: Vec<Option<V8Value>>,
363    /// Remaining node budget (fresh reads + reference clones).
364    budget: usize,
365}
366
367impl<'a> Reader<'a> {
368    fn new(data: &'a [u8], limits: V8Limits) -> Self {
369        let budget = limits.max_nodes;
370        Self {
371            data,
372            pos: 0,
373            limits,
374            id_map: Vec::new(),
375            budget,
376        }
377    }
378
379    fn peek(&self) -> Option<u8> {
380        self.data.get(self.pos).copied()
381    }
382
383    fn read_u8(&mut self) -> Result<u8, V8Error> {
384        let b = self.data.get(self.pos).copied().ok_or(V8Error::Truncated {
385            offset: self.pos,
386            needed: 1,
387            available: 0,
388        })?;
389        self.pos += 1;
390        Ok(b)
391    }
392
393    fn take(&mut self, n: usize) -> Result<&'a [u8], V8Error> {
394        let end = self.pos.checked_add(n).ok_or(V8Error::Truncated {
395            offset: self.pos,
396            needed: n,
397            available: self.data.len().saturating_sub(self.pos),
398        })?;
399        let slice = self.data.get(self.pos..end).ok_or(V8Error::Truncated {
400            offset: self.pos,
401            needed: n,
402            available: self.data.len().saturating_sub(self.pos),
403        })?;
404        self.pos = end;
405        Ok(slice)
406    }
407
408    /// Unsigned LEB128 varint, capped at 10 bytes (64 bits).
409    fn read_varint(&mut self) -> Result<u64, V8Error> {
410        let start = self.pos;
411        let mut result: u64 = 0;
412        let mut shift: u32 = 0;
413        loop {
414            if shift >= 64 {
415                return Err(V8Error::BadVarint { offset: start });
416            }
417            let byte = self.read_u8()?;
418            result |= u64::from(byte & 0x7f) << shift;
419            if byte & 0x80 == 0 {
420                return Ok(result);
421            }
422            shift += 7;
423        }
424    }
425
426    /// Zig-zag decoded signed varint (`kInt32`).
427    fn read_zigzag(&mut self) -> Result<i64, V8Error> {
428        let u = self.read_varint()?;
429        // (u >> 1) ^ -(u & 1)
430        Ok(((u >> 1) as i64) ^ -((u & 1) as i64))
431    }
432
433    fn read_f64_le(&mut self) -> Result<f64, V8Error> {
434        let b = self.take(8)?;
435        let mut arr = [0u8; 8];
436        arr.copy_from_slice(b);
437        Ok(f64::from_le_bytes(arr))
438    }
439
440    fn read_version_header(&mut self) -> Result<(), V8Error> {
441        let offset = self.pos;
442        let tag = self.read_u8()?;
443        if tag != TAG_VERSION {
444            return Err(V8Error::BadVersion { offset, found: tag });
445        }
446        // Format version follows as a varint; we accept any (forward-compatible).
447        let _version = self.read_varint()?;
448        Ok(())
449    }
450
451    /// Charge one node against the budget (call once per materialized value).
452    fn charge(&mut self, n: usize) -> Result<(), V8Error> {
453        if n > self.budget {
454            return Err(V8Error::NodeCap {
455                cap: self.limits.max_nodes,
456            });
457        }
458        self.budget -= n;
459        Ok(())
460    }
461
462    /// Reserve the next object id (before reading contents, matching V8, so
463    /// forward references in acyclic data resolve).
464    fn reserve_id(&mut self) -> usize {
465        let id = self.id_map.len();
466        self.id_map.push(None);
467        id
468    }
469
470    fn fill_id(&mut self, id: usize, value: &V8Value) {
471        if let Some(slot) = self.id_map.get_mut(id) {
472            *slot = Some(value.clone());
473        }
474    }
475
476    fn read_value(&mut self, depth: usize) -> Result<V8Value, V8Error> {
477        if depth > self.limits.max_depth {
478            return Err(V8Error::DepthCap {
479                offset: self.pos,
480                cap: self.limits.max_depth,
481            });
482        }
483        self.charge(1)?;
484        let offset = self.pos;
485        let tag = self.read_u8()?;
486        match tag {
487            TAG_UNDEFINED => Ok(V8Value::Undefined),
488            TAG_NULL => Ok(V8Value::Null),
489            TAG_THE_HOLE => Ok(V8Value::Hole),
490            TAG_TRUE => Ok(V8Value::Bool(true)),
491            TAG_FALSE => Ok(V8Value::Bool(false)),
492            TAG_INT32 => Ok(V8Value::Int(self.read_zigzag()?)),
493            TAG_UINT32 => Ok(V8Value::Int(self.read_varint()? as i64)),
494            TAG_DOUBLE => Ok(V8Value::Double(self.read_f64_le()?)),
495            TAG_BIGINT => Ok(V8Value::BigInt(self.read_bigint()?)),
496            TAG_UTF8_STRING => Ok(V8Value::String(self.read_utf8_string()?)),
497            TAG_ONE_BYTE_STRING => Ok(V8Value::String(self.read_one_byte_string()?)),
498            TAG_TWO_BYTE_STRING => Ok(V8Value::String(self.read_two_byte_string()?)),
499            TAG_DATE => {
500                let id = self.reserve_id();
501                let v = V8Value::Date(self.read_f64_le()?);
502                self.fill_id(id, &v);
503                Ok(v)
504            }
505            TAG_BEGIN_OBJECT => self.read_js_object(depth),
506            TAG_BEGIN_DENSE_ARRAY => self.read_dense_array(depth),
507            TAG_BEGIN_SPARSE_ARRAY => self.read_sparse_array(depth),
508            TAG_BEGIN_MAP => self.read_map(depth),
509            TAG_BEGIN_SET => self.read_set(depth),
510            TAG_ARRAY_BUFFER => self.read_array_buffer(),
511            TAG_REGEXP => self.read_regexp(depth),
512            TAG_NUMBER_OBJECT => {
513                let id = self.reserve_id();
514                let v = V8Value::NumberObject(self.read_f64_le()?);
515                self.fill_id(id, &v);
516                Ok(v)
517            }
518            TAG_TRUE_OBJECT => {
519                let id = self.reserve_id();
520                let v = V8Value::BooleanObject(true);
521                self.fill_id(id, &v);
522                Ok(v)
523            }
524            TAG_FALSE_OBJECT => {
525                let id = self.reserve_id();
526                let v = V8Value::BooleanObject(false);
527                self.fill_id(id, &v);
528                Ok(v)
529            }
530            TAG_STRING_OBJECT => {
531                let id = self.reserve_id();
532                let inner = self.read_value(depth + 1)?;
533                let V8Value::String(s) = inner else {
534                    return Err(V8Error::UnsupportedTag { offset, tag });
535                };
536                let v = V8Value::StringObject(s);
537                self.fill_id(id, &v);
538                Ok(v)
539            }
540            TAG_BIGINT_OBJECT => {
541                let id = self.reserve_id();
542                let v = V8Value::BigIntObject(self.read_bigint()?);
543                self.fill_id(id, &v);
544                Ok(v)
545            }
546            TAG_OBJECT_REFERENCE => self.read_reference(),
547            TAG_HOST_OBJECT => {
548                let blink_tag = self.read_u8().unwrap_or(0);
549                Err(V8Error::HostObject { offset, blink_tag })
550            }
551            _ => Err(V8Error::UnsupportedTag { offset, tag }),
552        }
553    }
554
555    fn read_utf8_string(&mut self) -> Result<String, V8Error> {
556        let raw = self.read_varint()?;
557        let len = self.checked_len(raw)?;
558        let bytes = self.take(len)?;
559        Ok(String::from_utf8_lossy(bytes).into_owned())
560    }
561
562    fn read_one_byte_string(&mut self) -> Result<String, V8Error> {
563        let raw = self.read_varint()?;
564        let len = self.checked_len(raw)?;
565        let bytes = self.take(len)?;
566        // One-byte strings are Latin-1 (ISO-8859-1): each byte is a code point.
567        Ok(bytes.iter().map(|&b| b as char).collect())
568    }
569
570    fn read_two_byte_string(&mut self) -> Result<String, V8Error> {
571        let offset = self.pos;
572        let raw = self.read_varint()?;
573        let len = self.checked_len(raw)?;
574        if len % 2 != 0 {
575            return Err(V8Error::OddUtf16 { offset, len });
576        }
577        let bytes = self.take(len)?;
578        let units: Vec<u16> = bytes
579            .chunks_exact(2)
580            .map(|c| u16::from_le_bytes([c[0], c[1]]))
581            .collect();
582        Ok(String::from_utf16_lossy(&units))
583    }
584
585    fn read_bigint(&mut self) -> Result<String, V8Error> {
586        let offset = self.pos;
587        let bitfield = self.read_varint()?;
588        let negative = bitfield & 1 == 1;
589        let byte_len = self.checked_len(bitfield >> 1)?;
590        let digits = self.take(byte_len)?;
591        let magnitude = le_bytes_to_decimal(digits);
592        if magnitude == "0" {
593            // A zero bigint is never negative.
594            return Ok(magnitude);
595        }
596        if negative {
597            Ok(format!("-{magnitude}"))
598        } else {
599            let _ = offset;
600            Ok(magnitude)
601        }
602    }
603
604    fn read_array_buffer(&mut self) -> Result<V8Value, V8Error> {
605        let id = self.reserve_id();
606        let raw = self.read_varint()?;
607        let len = self.checked_len(raw)?;
608        let bytes = self.take(len)?.to_vec();
609        let v = V8Value::ArrayBuffer(bytes);
610        self.fill_id(id, &v);
611        Ok(v)
612    }
613
614    fn read_regexp(&mut self, depth: usize) -> Result<V8Value, V8Error> {
615        let offset = self.pos;
616        let id = self.reserve_id();
617        let V8Value::String(source) = self.read_value(depth + 1)? else {
618            return Err(V8Error::BadKey { offset });
619        };
620        let flags = u32::try_from(self.read_varint()?).unwrap_or(u32::MAX);
621        let v = V8Value::RegExp { source, flags };
622        self.fill_id(id, &v);
623        Ok(v)
624    }
625
626    fn read_js_object(&mut self, depth: usize) -> Result<V8Value, V8Error> {
627        let id = self.reserve_id();
628        let mut props = Vec::new();
629        loop {
630            if self.peek() == Some(TAG_END_OBJECT) {
631                self.pos += 1;
632                break;
633            }
634            let key = self.read_property_key(depth + 1)?;
635            let value = self.read_value(depth + 1)?;
636            props.push((key, value));
637        }
638        // Trailing property count (validated by V8; we consume it).
639        let _count = self.read_varint()?;
640        let v = V8Value::Object(props);
641        self.fill_id(id, &v);
642        Ok(v)
643    }
644
645    fn read_dense_array(&mut self, depth: usize) -> Result<V8Value, V8Error> {
646        let raw = self.read_varint()?;
647        let length = self.checked_len(raw)?;
648        let id = self.reserve_id();
649        let mut elems = Vec::new();
650        for _ in 0..length {
651            elems.push(self.read_value(depth + 1)?);
652        }
653        // kEndDenseJSArray, then property count, then a repeat of the length.
654        let offset = self.pos;
655        let end = self.read_u8()?;
656        if end != TAG_END_DENSE_ARRAY {
657            return Err(V8Error::UnsupportedTag { offset, tag: end });
658        }
659        let num_props = self.read_varint()?;
660        for _ in 0..num_props {
661            // Any trailing named properties on the array — consume to stay in sync.
662            let _k = self.read_property_key(depth + 1)?;
663            let _v = self.read_value(depth + 1)?;
664        }
665        let _length_again = self.read_varint()?;
666        let v = V8Value::Array(elems);
667        self.fill_id(id, &v);
668        Ok(v)
669    }
670
671    fn read_sparse_array(&mut self, depth: usize) -> Result<V8Value, V8Error> {
672        let raw = self.read_varint()?;
673        let length = self.checked_len(raw)?;
674        let id = self.reserve_id();
675        // Charge the materialized (hole-filled) length up front so a huge declared
676        // length that has no backing bytes still fails loud.
677        self.charge(length)?;
678        let mut elems = vec![V8Value::Hole; length];
679        loop {
680            if self.peek() == Some(TAG_END_SPARSE_ARRAY) {
681                self.pos += 1;
682                break;
683            }
684            let key = self.read_value(depth + 1)?;
685            let value = self.read_value(depth + 1)?;
686            // Integer keys are array indices; named keys are extra properties we do
687            // not attach to the positional array (kept as holes).
688            if let V8Value::Int(i) = key {
689                if let Ok(idx) = usize::try_from(i) {
690                    if idx < elems.len() {
691                        elems[idx] = value;
692                    }
693                }
694            }
695        }
696        let _num_props = self.read_varint()?;
697        let _length_again = self.read_varint()?;
698        let v = V8Value::Array(elems);
699        self.fill_id(id, &v);
700        Ok(v)
701    }
702
703    fn read_map(&mut self, depth: usize) -> Result<V8Value, V8Error> {
704        let id = self.reserve_id();
705        let mut entries = Vec::new();
706        loop {
707            if self.peek() == Some(TAG_END_MAP) {
708                self.pos += 1;
709                break;
710            }
711            let key = self.read_value(depth + 1)?;
712            let value = self.read_value(depth + 1)?;
713            entries.push((key, value));
714        }
715        // Trailing count (= 2 × entries).
716        let _count = self.read_varint()?;
717        let v = V8Value::Map(entries);
718        self.fill_id(id, &v);
719        Ok(v)
720    }
721
722    fn read_set(&mut self, depth: usize) -> Result<V8Value, V8Error> {
723        let id = self.reserve_id();
724        let mut members = Vec::new();
725        loop {
726            if self.peek() == Some(TAG_END_SET) {
727                self.pos += 1;
728                break;
729            }
730            members.push(self.read_value(depth + 1)?);
731        }
732        let _count = self.read_varint()?;
733        let v = V8Value::Set(members);
734        self.fill_id(id, &v);
735        Ok(v)
736    }
737
738    /// A property key is a serialized value; V8 emits strings and integer indices.
739    fn read_property_key(&mut self, depth: usize) -> Result<String, V8Error> {
740        let offset = self.pos;
741        match self.read_value(depth)? {
742            V8Value::String(s) => Ok(s),
743            V8Value::Int(i) => Ok(i.to_string()),
744            V8Value::Double(d) => Ok(format!("{d}")),
745            _ => Err(V8Error::BadKey { offset }),
746        }
747    }
748
749    fn read_reference(&mut self) -> Result<V8Value, V8Error> {
750        let offset = self.pos;
751        let id = self.read_varint()?;
752        // cov:unreachable: usize::try_from(u64) is infallible on 64-bit targets (the
753        // CI/coverage platform); the map_err arm guards a 32-bit `id` overflow only.
754        let idx = usize::try_from(id).map_err(|_| V8Error::BadReference { offset, id })?;
755        let value = self
756            .id_map
757            .get(idx)
758            .cloned()
759            .flatten()
760            .ok_or(V8Error::BadReference { offset, id })?;
761        // A referenced subtree is a fresh materialization — charge it so N
762        // references to a large object cannot amplify memory past the cap.
763        self.charge(count_nodes(&value))?;
764        Ok(value)
765    }
766
767    /// Reject a declared length that exceeds the node cap before allocating.
768    fn checked_len(&self, len: u64) -> Result<usize, V8Error> {
769        let cap = self.limits.max_nodes as u64;
770        if len > cap {
771            return Err(V8Error::LengthCap {
772                offset: self.pos,
773                len,
774                cap: self.limits.max_nodes,
775            });
776        }
777        // The guard above already rejected len > cap (max_nodes, 4_000_000), so len
778        // fits usize on every supported target; this map_err is a defense-in-depth
779        // backstop no input can reach.
780        // cov:unreachable: len <= cap after the guard makes usize::try_from infallible.
781        usize::try_from(len).map_err(|_| V8Error::LengthCap {
782            offset: self.pos,
783            len,
784            cap: self.limits.max_nodes,
785        })
786    }
787}
788
789/// Count materialized nodes in a value (for reference-clone budget charging).
790fn count_nodes(v: &V8Value) -> usize {
791    match v {
792        V8Value::Array(items) | V8Value::Set(items) => {
793            1 + items.iter().map(count_nodes).sum::<usize>()
794        }
795        V8Value::Object(kv) => 1 + kv.iter().map(|(_, val)| count_nodes(val)).sum::<usize>(),
796        V8Value::Map(kv) => {
797            1 + kv
798                .iter()
799                .map(|(k, val)| count_nodes(k) + count_nodes(val))
800                .sum::<usize>()
801        }
802        _ => 1,
803    }
804}
805
806/// Render a little-endian byte magnitude to a decimal string (schoolbook
807/// base-256 → base-10). Returns `"0"` for an empty / all-zero magnitude.
808fn le_bytes_to_decimal(bytes: &[u8]) -> String {
809    let mut decimal: Vec<u8> = vec![0]; // little-endian decimal digits
810    for &byte in bytes.iter().rev() {
811        let mut carry = u32::from(byte);
812        for d in &mut decimal {
813            let v = u32::from(*d) * 256 + carry;
814            *d = (v % 10) as u8;
815            carry = v / 10;
816        }
817        while carry > 0 {
818            decimal.push((carry % 10) as u8);
819            carry /= 10;
820        }
821    }
822    // Strip leading zeros (most-significant end), then render most-significant first.
823    while decimal.len() > 1 && *decimal.last().unwrap_or(&0) == 0 {
824        decimal.pop();
825    }
826    decimal.iter().rev().map(|d| (b'0' + d) as char).collect()
827}