Skip to main content

steam_vdf_parser/binary/
parser.rs

1//! Binary VDF parser implementation.
2//!
3//! Supports Steam's binary VDF formats:
4//! - shortcuts.vdf (simple binary format)
5//! - appinfo.vdf (with optional string table)
6
7use alloc::borrow::Cow;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::vec::Vec;
11use core::str;
12
13use crate::binary::byte_reader::{read_u32_le, read_u64_le};
14use crate::binary::options::{InvalidUtf8, ParseOptions};
15use crate::binary::types::{
16    APPINFO_MAGIC_40, APPINFO_MAGIC_41, BinaryType, PACKAGEINFO_MAGIC_39, PACKAGEINFO_MAGIC_40,
17    PACKAGEINFO_MAGIC_BASE,
18};
19use crate::error::{Error, Result, with_offset};
20use crate::value::{Obj, Value, Vdf};
21
22// ===== Appinfo Header Constants =====
23
24/// Size of the appinfo entry header (up to and including the size field).
25const APPINFO_HEADER_SIZE: usize = 8;
26
27/// Size of the header after the size field (60 bytes).
28const APPINFO_HEADER_AFTER_SIZE: usize = 60;
29
30/// Total size of the appinfo entry header.
31const APPINFO_ENTRY_HEADER_SIZE: usize = APPINFO_HEADER_SIZE + APPINFO_HEADER_AFTER_SIZE;
32
33/// Offset where VDF data starts within an appinfo entry.
34const APPINFO_VDF_DATA_OFFSET: usize = APPINFO_ENTRY_HEADER_SIZE;
35
36// ===== Helper Functions =====
37
38/// Read a little-endian u32 from the start of a slice, returning an error if too small.
39fn ensure_read_u32_le(input: &[u8]) -> Result<(&[u8], u32)> {
40    read_u32_le(input)
41        .map(|value| (&input[4..], value))
42        .ok_or(Error::UnexpectedEndOfInput {
43            context: "reading u32",
44            offset: 0,
45            expected: 4,
46            actual: input.len(),
47        })
48}
49
50/// Parse configuration for binary VDF formats.
51///
52/// Encapsulates the differences between shortcuts.vdf and appinfo.vdf formats.
53#[derive(Clone, Copy, Debug, PartialEq, Default)]
54struct ParseConfig<'input, 'table> {
55    /// Strategy for parsing keys
56    key_mode: KeyMode<'input, 'table>,
57    /// How to handle strings that are not valid UTF-8
58    invalid_utf8: InvalidUtf8,
59}
60
61/// Key parsing strategy for binary VDF formats.
62#[derive(Clone, Copy, Debug, PartialEq, Default)]
63enum KeyMode<'input, 'table> {
64    /// Parse keys as null-terminated UTF-8 strings (v40, shortcuts)
65    #[default]
66    NullTerminated,
67    /// Parse keys as u32 indices into string table (v41)
68    StringTableIndex {
69        string_table: &'table StringTable<'input>,
70    },
71}
72
73/// String table for v41 appinfo format.
74///
75/// Encapsulates pre-extracted strings from the string table section,
76/// enabling O(1) lookups by index.
77#[derive(Clone, Debug, PartialEq)]
78struct StringTable<'a> {
79    strings: Vec<Cow<'a, str>>,
80}
81
82impl<'a> StringTable<'a> {
83    /// Get a string by index.
84    ///
85    /// Cloning a `Cow::Borrowed` entry only copies the borrow (preserving the
86    /// zero-copy `'a` lifetime); a `Cow::Owned` entry (produced by lossy
87    /// decoding of invalid bytes) is cloned into a new `String`.
88    fn get(&self, index: usize) -> Result<Cow<'a, str>> {
89        self.strings
90            .get(index)
91            .cloned()
92            .ok_or(Error::InvalidStringIndex {
93                index,
94                max: self.strings.len(),
95            })
96    }
97}
98
99impl<'a> KeyMode<'a, '_> {
100    /// Parse a key from input according to this mode.
101    ///
102    /// `invalid_utf8` only matters for [`KeyMode::NullTerminated`]; string-table
103    /// entries are decoded once when the table is parsed.
104    fn parse_key(
105        &self,
106        input: &'a [u8],
107        invalid_utf8: InvalidUtf8,
108    ) -> Result<(&'a [u8], Cow<'a, str>)> {
109        match self {
110            KeyMode::NullTerminated => {
111                let (rest, s) = parse_null_terminated_string(input, invalid_utf8)?;
112                Ok((rest, s))
113            }
114            KeyMode::StringTableIndex { string_table } => {
115                let (rest, index) = ensure_read_u32_le(input)?;
116                let s = string_table.get(index as usize)?;
117                Ok((rest, s))
118            }
119        }
120    }
121}
122
123/// Parse binary VDF data (autodetects format).
124///
125/// Attempts to parse as appinfo.vdf first, then falls back to shortcuts.vdf format.
126/// For shortcuts format, returns zero-copy data borrowed from input.
127/// For appinfo format, returns mixed data: root key and app ID keys are owned,
128/// but actual parsed values (including string table entries) are borrowed.
129/// For packageinfo format, returns mixed data similar to appinfo.
130///
131/// Uses default [`ParseOptions`] (lossy handling of invalid UTF-8). Use
132/// [`parse_with`] to override.
133pub fn parse(input: &[u8]) -> Result<Vdf<'_>> {
134    parse_with(input, ParseOptions::default())
135}
136
137/// Parse binary VDF data (autodetects format) with explicit [`ParseOptions`].
138pub fn parse_with(input: &[u8], options: ParseOptions) -> Result<Vdf<'_>> {
139    // Check if this looks like appinfo or packageinfo format (starts with magic)
140    if let Some(magic) = read_u32_le(input) {
141        if magic == APPINFO_MAGIC_40 || magic == APPINFO_MAGIC_41 {
142            return parse_appinfo_with(input, options);
143        }
144        if magic == PACKAGEINFO_MAGIC_39 || magic == PACKAGEINFO_MAGIC_40 {
145            return parse_packageinfo_with(input, options);
146        }
147    }
148
149    // Otherwise, parse as shortcuts format (zero-copy)
150    parse_shortcuts_with(input, options)
151}
152
153/// Parse shortcuts.vdf format binary data.
154///
155/// This is the simpler binary format used by Steam for shortcuts and other data.
156///
157/// This function returns zero-copy data - strings are borrowed from the input buffer.
158///
159/// Format:
160/// - Each entry starts with a type byte
161/// - Type 0x00: Object start (key is the object name)
162/// - Type 0x01: String value
163/// - Type 0x02: Int32 value
164/// - Type 0x08: Object end
165///
166/// All strings are null-terminated.
167///
168/// Uses default [`ParseOptions`] (lossy handling of invalid UTF-8). Use
169/// [`parse_shortcuts_with`] to override.
170pub fn parse_shortcuts(input: &[u8]) -> Result<Vdf<'_>> {
171    parse_shortcuts_with(input, ParseOptions::default())
172}
173
174/// Parse shortcuts.vdf format binary data with explicit [`ParseOptions`].
175pub fn parse_shortcuts_with(input: &[u8], options: ParseOptions) -> Result<Vdf<'_>> {
176    let config = ParseConfig {
177        invalid_utf8: options.invalid_utf8,
178        ..ParseConfig::default()
179    };
180    let (_rest, obj) = parse_object(input, &config)?;
181
182    Ok(Vdf::new("root", Value::Obj(obj)))
183}
184
185/// Parse appinfo.vdf format binary data.
186///
187/// This function returns zero-copy data where possible - strings are borrowed from
188/// the input buffer (including string table entries in v41 format).
189///
190/// Format:
191/// - 4 bytes: Magic number (0x07564428 or 0x07564429)
192/// - 4 bytes: Universe
193/// - If magic == 0x07564429: 8 bytes: String table offset
194/// - Apps continue until EOF (or string table for v41)
195/// - For each app:
196///   - 4 bytes: App ID
197///   - 4 bytes: Size (remaining data size for this entry)
198///   - 4 bytes: InfoState
199///   - 4 bytes: LastUpdated (Unix timestamp)
200///   - 8 bytes: AccessToken
201///   - 20 bytes: SHA1 of text data
202///   - 4 bytes: ChangeNumber
203///   - 20 bytes: SHA1 of binary data
204///   - Then the VDF data for the app (starts with 0x00)
205/// - String table (if magic == 0x07564429, at string_table_offset)
206///
207/// App entry header is `APPINFO_ENTRY_HEADER_SIZE` (68) bytes.
208///
209/// Uses default [`ParseOptions`] (lossy handling of invalid UTF-8). Use
210/// [`parse_appinfo_with`] to override.
211pub fn parse_appinfo(input: &[u8]) -> Result<Vdf<'_>> {
212    parse_appinfo_with(input, ParseOptions::default())
213}
214
215/// Parse appinfo.vdf format binary data with explicit [`ParseOptions`].
216pub fn parse_appinfo_with(input: &[u8], options: ParseOptions) -> Result<Vdf<'_>> {
217    if input.len() < 16 {
218        return Err(Error::UnexpectedEndOfInput {
219            context: "reading appinfo header",
220            offset: input.len(),
221            expected: 16,
222            actual: input.len(),
223        });
224    }
225
226    let Some(magic) = read_u32_le(input) else {
227        return Err(Error::UnexpectedEndOfInput {
228            context: "reading magic number",
229            offset: 0,
230            expected: 4,
231            actual: input.len(),
232        });
233    };
234    let Some(universe) = read_u32_le(&input[4..]) else {
235        return Err(Error::UnexpectedEndOfInput {
236            context: "reading universe",
237            offset: 4,
238            expected: 4,
239            actual: input.len() - 4,
240        });
241    };
242
243    let (string_table_offset, mut rest) = match magic {
244        APPINFO_MAGIC_40 => (None, &input[8..]),
245        APPINFO_MAGIC_41 => {
246            let Some(offset) = read_u64_le(&input[8..]) else {
247                return Err(Error::UnexpectedEndOfInput {
248                    context: "reading string table offset",
249                    offset: 8,
250                    expected: 8,
251                    actual: input.len() - 8,
252                });
253            };
254            (Some(offset as usize), &input[16..])
255        }
256        _ => {
257            return Err(Error::InvalidMagic {
258                found: magic,
259                expected: &[APPINFO_MAGIC_40, APPINFO_MAGIC_41],
260            });
261        }
262    };
263
264    // Parse the string table if present
265    let string_table = if let Some(offset) = string_table_offset {
266        if offset >= input.len() {
267            return Err(Error::UnexpectedEndOfInput {
268                context: "reading string table",
269                offset,
270                expected: 4,
271                actual: input.len() - offset,
272            });
273        }
274        Some(
275            parse_string_table(&input[offset..], options.invalid_utf8)
276                .map_err(with_offset(offset))?,
277        )
278    } else {
279        None
280    };
281
282    let mut obj = Obj::new();
283
284    // Calculate where apps end (at string table for v41, or EOF for v40)
285    let apps_end_offset = string_table_offset.unwrap_or(input.len());
286
287    // Use v41 format (string table) if string_table_offset is Some
288    let config = ParseConfig {
289        key_mode: if let Some(string_table) = &string_table {
290            KeyMode::StringTableIndex { string_table }
291        } else {
292            KeyMode::NullTerminated
293        },
294        invalid_utf8: options.invalid_utf8,
295    };
296
297    loop {
298        // Check if we've reached the end of apps section
299        let current_offset = input.len() - rest.len();
300        if current_offset >= apps_end_offset {
301            break;
302        }
303
304        // Not enough data for an app entry header.
305        if rest.len() < APPINFO_ENTRY_HEADER_SIZE {
306            return Err(Error::UnexpectedEndOfInput {
307                context: "reading app entry header",
308                offset: current_offset,
309                expected: APPINFO_ENTRY_HEADER_SIZE,
310                actual: rest.len(),
311            });
312        }
313
314        // App ID (offset 0)
315        let Some(app_id) = read_u32_le(rest) else {
316            return Err(Error::UnexpectedEndOfInput {
317                context: "reading app id",
318                offset: current_offset,
319                expected: 4,
320                actual: rest.len(),
321            });
322        };
323        if app_id == 0 {
324            break;
325        }
326
327        // Size (offset 4) - includes everything AFTER this field (APPINFO_HEADER_AFTER_SIZE bytes + VDF data)
328        let Some(size) = read_u32_le(&rest[4..]) else {
329            return Err(Error::UnexpectedEndOfInput {
330                context: "reading entry size",
331                offset: current_offset + 4,
332                expected: 4,
333                actual: rest.len() - 4,
334            });
335        };
336        let size = size as usize;
337
338        // VDF data starts after the header
339        let vdf_size = size - APPINFO_HEADER_AFTER_SIZE;
340        let vdf_end = APPINFO_VDF_DATA_OFFSET + vdf_size;
341
342        if vdf_end > rest.len() {
343            return Err(Error::UnexpectedEndOfInput {
344                context: "reading VDF data",
345                offset: current_offset + vdf_end,
346                expected: vdf_end,
347                actual: rest.len(),
348            });
349        }
350
351        let vdf_data = &rest[APPINFO_VDF_DATA_OFFSET..vdf_end];
352        let vdf_offset = current_offset + APPINFO_VDF_DATA_OFFSET;
353
354        let (_vdf_rest, app_obj) =
355            parse_object(vdf_data, &config).map_err(with_offset(vdf_offset))?;
356
357        // Insert with app ID as key
358        obj.insert(Cow::Owned(app_id.to_string()), Value::Obj(app_obj));
359        rest = &rest[vdf_end..];
360    }
361
362    Ok(Vdf::new(
363        format!("appinfo_universe_{}", universe),
364        Value::Obj(obj),
365    ))
366}
367
368/// Parses an object from binary VDF data.
369///
370/// This function implements a state machine that:
371/// 1. Reads a type byte to determine the entry type
372/// 2. Parses a key (format depends on `config.key_mode`)
373/// 3. Parses the value based on the type byte
374/// 4. Inserts the key-value pair into the object
375/// 5. Returns on `ObjectEnd` (0x08) marker
376///
377/// # Parameters
378/// - `input`: Binary data to parse
379/// - `config`: Parse configuration including string table reference
380///
381/// # Returns
382/// A tuple of remaining input and the parsed object.
383fn parse_object<'a>(input: &'a [u8], config: &ParseConfig<'a, '_>) -> Result<(&'a [u8], Obj<'a>)> {
384    let mut obj = Obj::new();
385    let mut rest = input;
386
387    loop {
388        match rest {
389            [] => {
390                // At root level, EOF is acceptable - file may end without trailing 0x08
391                break Ok((rest, obj));
392            }
393            [type_byte, remainder @ ..] => {
394                let type_byte = *type_byte;
395                let typ = BinaryType::from_byte(type_byte);
396                let offset = input.len() - remainder.len();
397                rest = remainder;
398
399                match typ {
400                    Some(BinaryType::ObjectEnd) => {
401                        // Consume the end marker and return
402                        return Ok((rest, obj));
403                    }
404                    Some(BinaryType::None) => {
405                        // Map entry: 0x00 [key] { ... entries ... }
406                        let key_offset = input.len() - rest.len();
407                        let (new_rest, key) = config
408                            .key_mode
409                            .parse_key(rest, config.invalid_utf8)
410                            .map_err(with_offset(key_offset))?;
411                        let (new_rest, nested_obj) = parse_object(new_rest, config)?;
412                        obj.insert(key, Value::Obj(nested_obj));
413                        rest = new_rest;
414                    }
415                    Some(BinaryType::String) => {
416                        // String entry: 0x01 [key] [value]
417                        // VALUE is ALWAYS inline null-terminated string (never from string table!)
418                        let key_offset = input.len() - rest.len();
419                        let (new_rest, key) = config
420                            .key_mode
421                            .parse_key(rest, config.invalid_utf8)
422                            .map_err(with_offset(key_offset))?;
423                        let value_offset = input.len() - new_rest.len();
424                        let (new_rest, value) =
425                            parse_null_terminated_string(new_rest, config.invalid_utf8)
426                                .map_err(with_offset(value_offset))?;
427                        obj.insert(key, Value::Str(value));
428                        rest = new_rest;
429                    }
430                    Some(BinaryType::Int32) => {
431                        let key_offset = input.len() - rest.len();
432                        let (new_rest, key) = config
433                            .key_mode
434                            .parse_key(rest, config.invalid_utf8)
435                            .map_err(with_offset(key_offset))?;
436                        let value_offset = input.len() - new_rest.len();
437                        let (new_rest, value) =
438                            parse_value_int32(new_rest).map_err(with_offset(value_offset))?;
439                        obj.insert(key, value);
440                        rest = new_rest;
441                    }
442                    Some(BinaryType::UInt64) => {
443                        let key_offset = input.len() - rest.len();
444                        let (new_rest, key) = config
445                            .key_mode
446                            .parse_key(rest, config.invalid_utf8)
447                            .map_err(with_offset(key_offset))?;
448                        let value_offset = input.len() - new_rest.len();
449                        let (new_rest, value) =
450                            parse_value_uint64(new_rest).map_err(with_offset(value_offset))?;
451                        obj.insert(key, value);
452                        rest = new_rest;
453                    }
454                    Some(BinaryType::Float) => {
455                        let key_offset = input.len() - rest.len();
456                        let (new_rest, key) = config
457                            .key_mode
458                            .parse_key(rest, config.invalid_utf8)
459                            .map_err(with_offset(key_offset))?;
460                        let value_offset = input.len() - new_rest.len();
461                        let (new_rest, value) =
462                            parse_value_float(new_rest).map_err(with_offset(value_offset))?;
463                        obj.insert(key, value);
464                        rest = new_rest;
465                    }
466                    Some(BinaryType::Ptr) => {
467                        let key_offset = input.len() - rest.len();
468                        let (new_rest, key) = config
469                            .key_mode
470                            .parse_key(rest, config.invalid_utf8)
471                            .map_err(with_offset(key_offset))?;
472                        let value_offset = input.len() - new_rest.len();
473                        let (new_rest, value) =
474                            parse_value_ptr(new_rest).map_err(with_offset(value_offset))?;
475                        obj.insert(key, value);
476                        rest = new_rest;
477                    }
478                    Some(BinaryType::WString) => {
479                        let key_offset = input.len() - rest.len();
480                        let (new_rest, key) = config
481                            .key_mode
482                            .parse_key(rest, config.invalid_utf8)
483                            .map_err(with_offset(key_offset))?;
484                        let value_offset = input.len() - new_rest.len();
485                        let (new_rest, value) = parse_value_wstring(new_rest, config.invalid_utf8)
486                            .map_err(with_offset(value_offset))?;
487                        obj.insert(key, value);
488                        rest = new_rest;
489                    }
490                    Some(BinaryType::Color) => {
491                        let key_offset = input.len() - rest.len();
492                        let (new_rest, key) = config
493                            .key_mode
494                            .parse_key(rest, config.invalid_utf8)
495                            .map_err(with_offset(key_offset))?;
496                        let value_offset = input.len() - new_rest.len();
497                        let (new_rest, value) =
498                            parse_value_color(new_rest).map_err(with_offset(value_offset))?;
499                        obj.insert(key, value);
500                        rest = new_rest;
501                    }
502                    None => {
503                        // Unknown type byte
504                        return Err(Error::UnknownType { type_byte, offset });
505                    }
506                }
507            }
508        }
509    }
510}
511
512// ===== Value Parser Functions =====
513
514/// Parse an Int32 value (4 bytes, little-endian).
515fn parse_value_int32<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> {
516    let arr = <[u8; 4]>::try_from(input.get(..4).ok_or(Error::UnexpectedEndOfInput {
517        context: "reading int32",
518        offset: 0,
519        expected: 4,
520        actual: input.len(),
521    })?)
522    .map_err(|_| Error::UnexpectedEndOfInput {
523        context: "reading int32",
524        offset: 0,
525        expected: 4,
526        actual: input.len(),
527    })?;
528    let value = i32::from_le_bytes(arr);
529    Ok((&input[4..], Value::I32(value)))
530}
531
532/// Parse a UInt64 value (8 bytes, little-endian).
533fn parse_value_uint64<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> {
534    let arr = <[u8; 8]>::try_from(input.get(..8).ok_or(Error::UnexpectedEndOfInput {
535        context: "reading uint64",
536        offset: 0,
537        expected: 8,
538        actual: input.len(),
539    })?)
540    .map_err(|_| Error::UnexpectedEndOfInput {
541        context: "reading uint64",
542        offset: 0,
543        expected: 8,
544        actual: input.len(),
545    })?;
546    let value = u64::from_le_bytes(arr);
547    Ok((&input[8..], Value::U64(value)))
548}
549
550/// Parse a Float value (4 bytes, little-endian).
551fn parse_value_float<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> {
552    let arr = <[u8; 4]>::try_from(input.get(..4).ok_or(Error::UnexpectedEndOfInput {
553        context: "reading float",
554        offset: 0,
555        expected: 4,
556        actual: input.len(),
557    })?)
558    .map_err(|_| Error::UnexpectedEndOfInput {
559        context: "reading float",
560        offset: 0,
561        expected: 4,
562        actual: input.len(),
563    })?;
564    let value = f32::from_le_bytes(arr);
565    Ok((&input[4..], Value::Float(value)))
566}
567
568/// Parse a Pointer value (4 bytes, little-endian).
569fn parse_value_ptr<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> {
570    let (rest, value) = ensure_read_u32_le(input)?;
571    Ok((rest, Value::Pointer(value)))
572}
573
574/// Parse a WideString value (UTF-16LE, null-terminated).
575fn parse_value_wstring<'a>(
576    input: &'a [u8],
577    invalid_utf8: InvalidUtf8,
578) -> Result<(&'a [u8], Value<'a>)> {
579    let (rest, string) = parse_null_terminated_wstring(input, invalid_utf8)?;
580    Ok((rest, Value::Str(Cow::Owned(string))))
581}
582
583/// Parse a Color value (4 bytes RGBA).
584fn parse_value_color<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> {
585    let arr = <[u8; 4]>::try_from(input.get(..4).ok_or(Error::UnexpectedEndOfInput {
586        context: "reading color",
587        offset: 0,
588        expected: 4,
589        actual: input.len(),
590    })?)
591    .map_err(|_| Error::UnexpectedEndOfInput {
592        context: "reading color",
593        offset: 0,
594        expected: 4,
595        actual: input.len(),
596    })?;
597    Ok((&input[4..], Value::Color(arr)))
598}
599
600// ===== String Parsing Functions =====
601
602/// Decode a byte slice as UTF-8 according to `invalid_utf8`.
603///
604/// In [`InvalidUtf8::Strict`] mode, invalid bytes produce an
605/// [`Error::InvalidUtf8`]. In [`InvalidUtf8::Lossy`] mode, invalid sequences are
606/// replaced with U+FFFD; valid input is still borrowed zero-copy (only strings
607/// that actually contain invalid bytes are allocated).
608fn decode_utf8(bytes: &[u8], invalid_utf8: InvalidUtf8) -> Result<Cow<'_, str>> {
609    match invalid_utf8 {
610        InvalidUtf8::Strict => {
611            core::str::from_utf8(bytes)
612                .map(Cow::Borrowed)
613                .map_err(|e| Error::InvalidUtf8 {
614                    offset: e.valid_up_to(),
615                })
616        }
617        InvalidUtf8::Lossy => Ok(String::from_utf8_lossy(bytes)),
618    }
619}
620
621/// Parse a null-terminated string (UTF-8).
622///
623/// Zero-copy: the returned `Cow` borrows from the input when the bytes are valid
624/// UTF-8. Handling of invalid bytes is controlled by `invalid_utf8`.
625fn parse_null_terminated_string(
626    input: &[u8],
627    invalid_utf8: InvalidUtf8,
628) -> Result<(&[u8], Cow<'_, str>)> {
629    let null_pos = input
630        .iter()
631        .position(|&b| b == 0)
632        .ok_or(Error::UnexpectedEndOfInput {
633            context: "reading null-terminated string",
634            offset: 0,
635            expected: 1,
636            actual: input.len(),
637        })?;
638
639    let string = decode_utf8(&input[..null_pos], invalid_utf8)?;
640
641    Ok((&input[null_pos + 1..], string))
642}
643
644/// Parse a null-terminated wide string (UTF-16LE).
645///
646/// WideString is terminated by two zero bytes (0x00 0x00).
647/// Note: This allocates due to UTF-16 to UTF-8 conversion.
648fn parse_null_terminated_wstring(
649    input: &[u8],
650    invalid_utf8: InvalidUtf8,
651) -> Result<(&[u8], String)> {
652    // Find the double-null terminator
653    let mut i = 0;
654    while i + 1 < input.len() {
655        if input[i] == 0 && input[i + 1] == 0 {
656            break;
657        }
658        i += 2;
659    }
660
661    if i + 1 >= input.len() {
662        return Err(Error::UnexpectedEndOfInput {
663            context: "reading null-terminated wide string",
664            offset: i,
665            expected: 2,
666            actual: input.len().saturating_sub(i),
667        });
668    }
669
670    // Convert UTF-16LE to u16 code units
671    let utf16_units = input[..i]
672        .chunks_exact(2)
673        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]));
674
675    // Decode UTF-16 to char and then to String
676    let string: String = match invalid_utf8 {
677        InvalidUtf8::Strict => char::decode_utf16(utf16_units)
678            .enumerate()
679            .map(|(pos, r)| {
680                r.map_err(|_| Error::InvalidUtf16 {
681                    offset: pos * 2,
682                    position: pos,
683                })
684            })
685            .collect::<core::result::Result<_, _>>()?,
686        InvalidUtf8::Lossy => char::decode_utf16(utf16_units)
687            .map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER))
688            .collect(),
689    };
690
691    Ok((&input[i + 2..], string))
692}
693
694/// Parse the string table section (v41 format).
695///
696/// Returns a `StringTable` containing pre-extracted strings for O(1) lookups.
697///
698/// Format:
699/// - 4 bytes: string_count (little-endian u32)
700/// - Then string_count null-terminated UTF-8 strings
701fn parse_string_table(input: &[u8], invalid_utf8: InvalidUtf8) -> Result<StringTable<'_>> {
702    let (mut rest, string_count) = ensure_read_u32_le(input)?;
703    let string_count = string_count as usize;
704
705    let mut strings = Vec::with_capacity(string_count);
706
707    // Extract each null-terminated string
708    for _ in 0..string_count {
709        if rest.is_empty() {
710            return Err(Error::UnexpectedEndOfInput {
711                context: "reading string table entry",
712                offset: input.len() - rest.len(),
713                expected: 1,
714                actual: 0,
715            });
716        }
717        let (new_rest, string) = parse_null_terminated_string(rest, invalid_utf8)?;
718        strings.push(string);
719        rest = new_rest;
720    }
721
722    Ok(StringTable { strings })
723}
724
725/// Header size for packageinfo entries (package_id + hash + change_number + token).
726const PACKAGEINFO_ENTRY_HEADER_SIZE_V39: usize = 4 + 20 + 4; // package_id + hash + change_number
727const PACKAGEINFO_ENTRY_HEADER_SIZE_V40: usize = 4 + 20 + 4 + 8; // + token
728
729/// Parse packageinfo.vdf format binary data.
730///
731/// This function returns zero-copy data where possible - strings are borrowed from
732/// the input buffer.
733///
734/// Format:
735/// - 4 bytes: Magic number + version (0x06565527 for v39, 0x06565528 for v40)
736///   - Upper 3 bytes: 0x065655 (magic)
737///   - Lower 1 byte: version (27 = 39, 28 = 40)
738/// - 4 bytes: Universe
739/// - Repeated package entries until package_id == 0xFFFFFFFF:
740///   - 4 bytes: Package ID (uint32)
741///   - 20 bytes: SHA-1 hash
742///   - 4 bytes: Change number (uint32)
743///   - 8 bytes: PICS token (uint64, only in v40+)
744///   - Binary VDF blob (KeyValues1 binary) with package metadata
745///
746/// Uses default [`ParseOptions`] (lossy handling of invalid UTF-8). Use
747/// [`parse_packageinfo_with`] to override.
748pub fn parse_packageinfo(input: &[u8]) -> Result<Vdf<'_>> {
749    parse_packageinfo_with(input, ParseOptions::default())
750}
751
752/// Parse packageinfo.vdf format binary data with explicit [`ParseOptions`].
753pub fn parse_packageinfo_with(input: &[u8], options: ParseOptions) -> Result<Vdf<'_>> {
754    if input.len() < 8 {
755        return Err(Error::UnexpectedEndOfInput {
756            context: "reading packageinfo header",
757            offset: input.len(),
758            expected: 8,
759            actual: input.len(),
760        });
761    }
762
763    let Some(magic) = read_u32_le(input) else {
764        return Err(Error::UnexpectedEndOfInput {
765            context: "reading magic number",
766            offset: 0,
767            expected: 4,
768            actual: input.len(),
769        });
770    };
771
772    // Extract version from lower byte and magic from upper 3 bytes
773    let version = magic & 0xFF;
774    let magic_base = magic >> 8;
775
776    if magic_base != PACKAGEINFO_MAGIC_BASE {
777        return Err(Error::InvalidMagic {
778            found: magic,
779            expected: &[PACKAGEINFO_MAGIC_39, PACKAGEINFO_MAGIC_40],
780        });
781    }
782
783    if version != 39 && version != 40 {
784        return Err(Error::InvalidMagic {
785            found: magic,
786            expected: &[PACKAGEINFO_MAGIC_39, PACKAGEINFO_MAGIC_40],
787        });
788    }
789
790    let Some(universe) = read_u32_le(&input[4..]) else {
791        return Err(Error::UnexpectedEndOfInput {
792            context: "reading universe",
793            offset: 4,
794            expected: 4,
795            actual: input.len() - 4,
796        });
797    };
798
799    let has_token = version >= 40;
800    let header_size = if has_token {
801        PACKAGEINFO_ENTRY_HEADER_SIZE_V40
802    } else {
803        PACKAGEINFO_ENTRY_HEADER_SIZE_V39
804    };
805
806    let mut rest = &input[8..];
807    let mut obj = Obj::new();
808
809    loop {
810        // Check if we have at least 4 bytes for the package ID
811        if rest.len() < 4 {
812            // At EOF or termination marker, exit gracefully
813            break;
814        }
815
816        // Read package ID
817        let Some(package_id) = read_u32_le(rest) else {
818            break;
819        };
820
821        // Check for termination marker
822        if package_id == 0xFFFFFFFF {
823            break;
824        }
825
826        // Now ensure we have enough data for the full header
827        if rest.len() < header_size {
828            return Err(Error::UnexpectedEndOfInput {
829                context: "reading package entry header",
830                offset: input.len() - rest.len(),
831                expected: header_size,
832                actual: rest.len(),
833            });
834        }
835
836        // Skip hash (20 bytes), read change number
837        let hash_offset = 4;
838        let change_number_offset = hash_offset + 20;
839
840        let Some(change_number) = read_u32_le(&rest[change_number_offset..]) else {
841            return Err(Error::UnexpectedEndOfInput {
842                context: "reading change number",
843                offset: input.len() - rest.len() + change_number_offset,
844                expected: 4,
845                actual: rest.len() - change_number_offset,
846            });
847        };
848
849        // Skip token if present (8 bytes after change_number)
850        let vdf_data_offset = if has_token {
851            change_number_offset + 4 + 8
852        } else {
853            change_number_offset + 4
854        };
855
856        // Parse the VDF data for this package
857        let vdf_data = &rest[vdf_data_offset..];
858
859        // Uses null-terminated keys like shortcuts
860        let config = ParseConfig {
861            invalid_utf8: options.invalid_utf8,
862            ..ParseConfig::default()
863        };
864
865        let (_vdf_rest, package_obj) =
866            parse_object(vdf_data, &config).map_err(with_offset(input.len() - vdf_data.len()))?;
867
868        // Create metadata object for this package
869        let mut package_with_meta = Obj::new();
870
871        // Add metadata fields
872        package_with_meta.insert(Cow::Borrowed("packageid"), Value::I32(package_id as i32));
873        package_with_meta.insert(
874            Cow::Borrowed("change_number"),
875            Value::U64(change_number as u64),
876        );
877        package_with_meta.insert(
878            Cow::Borrowed("sha1"),
879            Value::Str(Cow::Owned(hex::encode(
880                &rest[hash_offset..hash_offset + 20],
881            ))),
882        );
883
884        // Merge the parsed VDF data
885        for (key, value) in package_obj.iter() {
886            package_with_meta.insert(key.clone(), value.clone());
887        }
888
889        // Insert with package ID as key
890        obj.insert(
891            Cow::Owned(package_id.to_string()),
892            Value::Obj(package_with_meta),
893        );
894
895        // Find the end of this VDF object to move to the next entry
896        // _vdf_rest from the first parse_object call above tells us where VDF data ended
897        let vdf_end = vdf_data.len() - _vdf_rest.len();
898        rest = &rest[vdf_data_offset + vdf_end..];
899    }
900
901    Ok(Vdf::new(
902        format!("packageinfo_universe_{}", universe),
903        Value::Obj(obj),
904    ))
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910
911    #[test]
912    fn test_parse_simple_object() {
913        // Simple binary VDF: "test" { "key" "value" }
914        let data: &[u8] = &[
915            0x00, // Object start
916            b't', b'e', b's', b't', 0x00, // Key "test"
917            0x01, // String type
918            b'k', b'e', b'y', 0x00, // Key "key"
919            b'v', b'a', b'l', b'u', b'e', 0x00, // Value "value"
920            0x08, // Object end
921        ];
922
923        let result = parse_shortcuts(data);
924        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
925
926        let vdf = result.unwrap();
927        assert_eq!(vdf.key(), "root");
928
929        let obj = vdf.as_obj().unwrap();
930        let test_obj = obj.get("test").and_then(|v| v.as_obj());
931        assert!(test_obj.is_some());
932
933        let test_obj = test_obj.unwrap();
934        let value = test_obj.get("key").and_then(|v| v.as_str());
935        assert_eq!(value, Some("value"));
936    }
937
938    #[test]
939    fn test_parse_nested_objects() {
940        // Nested objects: "outer" { "inner" { "key" "value" } }
941        let data: &[u8] = &[
942            0x00, // Object start
943            b'o', b'u', b't', b'e', b'r', 0x00, // Key "outer"
944            0x00, // Nested object start
945            b'i', b'n', b'n', b'e', b'r', 0x00, // Key "inner"
946            0x01, // String type
947            b'k', b'e', b'y', 0x00, // Key "key"
948            b'v', b'a', b'l', b'u', b'e', 0x00, // Value "value"
949            0x08, // End inner object
950            0x08, // End outer object
951        ];
952
953        let result = parse_shortcuts(data);
954        assert!(result.is_ok());
955
956        let vdf = result.unwrap();
957        let obj = vdf.as_obj().unwrap();
958        let outer = obj.get("outer").and_then(|v| v.as_obj()).unwrap();
959        let inner = outer.get("inner").and_then(|v| v.as_obj()).unwrap();
960        let value = inner.get("key").and_then(|v| v.as_str());
961        assert_eq!(value, Some("value"));
962    }
963
964    #[test]
965    fn test_parse_int32_value() {
966        // Int32 value: "root" { "number" "42" }
967        let data: &[u8] = &[
968            0x00, // Object start
969            b'r', b'o', b'o', b't', 0x00, // Key "root"
970            0x02, // Int32 type
971            b'n', b'u', b'm', b'b', b'e', b'r', 0x00, // Key "number"
972            42, 0, 0, 0,    // Value 42 (little-endian)
973            0x08, // Object end
974        ];
975
976        let result = parse_shortcuts(data);
977        assert!(result.is_ok());
978
979        let vdf = result.unwrap();
980        let obj = vdf.as_obj().unwrap();
981        let root = obj.get("root").and_then(|v| v.as_obj()).unwrap();
982        let value = root.get("number").and_then(|v| v.as_i32());
983        assert_eq!(value, Some(42));
984    }
985
986    #[test]
987    fn test_parse_uint64_value() {
988        // UInt64 value
989        let data: &[u8] = &[
990            0x00, // Object start
991            b'r', b'o', b'o', b't', 0x00, // Key "root"
992            0x07, // UInt64 type
993            b'n', b'u', b'm', b'b', b'e', b'r', 0x00, // Key "number"
994            0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, // Value u32::MAX as u64
995            0x08, // Object end
996        ];
997
998        let result = parse_shortcuts(data);
999        assert!(result.is_ok());
1000
1001        let vdf = result.unwrap();
1002        let obj = vdf.as_obj().unwrap();
1003        let root = obj.get("root").and_then(|v| v.as_obj()).unwrap();
1004        let value = root.get("number").and_then(|v| v.as_u64());
1005        assert_eq!(value, Some(4294967295));
1006    }
1007
1008    #[test]
1009    fn test_parse_float_value() {
1010        // Float value
1011        let data: &[u8] = &[
1012            0x00, // Object start
1013            b'r', b'o', b'o', b't', 0x00, // Key "root"
1014            0x03, // Float type
1015            b'v', b'a', b'l', 0x00, // Key "val"
1016            0x00, 0x00, 0x80, 0x3F, // Value 1.0 (little-endian)
1017            0x08, // Object end
1018        ];
1019
1020        let result = parse_shortcuts(data);
1021        assert!(result.is_ok());
1022
1023        let vdf = result.unwrap();
1024        let obj = vdf.as_obj().unwrap();
1025        let root = obj.get("root").and_then(|v| v.as_obj()).unwrap();
1026        let value = root.get("val").and_then(|v| v.as_float());
1027        assert_eq!(value, Some(1.0));
1028    }
1029
1030    #[test]
1031    fn test_parse_ptr_value() {
1032        // Pointer value
1033        let data: &[u8] = &[
1034            0x00, // Object start
1035            b'r', b'o', b'o', b't', 0x00, // Key "root"
1036            0x04, // Ptr type
1037            b'p', b't', b'r', 0x00, // Key "ptr"
1038            0xAB, 0xCD, 0xEF, 0x12, // Value 0x12EFCDAB
1039            0x08, // Object end
1040        ];
1041
1042        let result = parse_shortcuts(data);
1043        assert!(result.is_ok());
1044
1045        let vdf = result.unwrap();
1046        let obj = vdf.as_obj().unwrap();
1047        let root = obj.get("root").and_then(|v| v.as_obj()).unwrap();
1048        let value = root.get("ptr").and_then(|v| v.as_pointer());
1049        assert_eq!(value, Some(0x12efcdab));
1050    }
1051
1052    #[test]
1053    fn test_parse_color_value() {
1054        // Color value: RGBA (255, 0, 0, 255) = "25500255"
1055        let data: &[u8] = &[
1056            0x00, // Object start
1057            b'r', b'o', b'o', b't', 0x00, // Key "root"
1058            0x06, // Color type
1059            b'c', b'o', b'l', 0x00, // Key "col"
1060            0xFF, 0x00, 0x00, 0xFF, // RGBA: red, opaque
1061            0x08, // Object end
1062        ];
1063
1064        let result = parse_shortcuts(data);
1065        assert!(result.is_ok());
1066
1067        let vdf = result.unwrap();
1068        let obj = vdf.as_obj().unwrap();
1069        let root = obj.get("root").and_then(|v| v.as_obj()).unwrap();
1070        let value = root.get("col").and_then(|v| v.as_color());
1071        assert_eq!(value, Some([255, 0, 0, 255]));
1072    }
1073
1074    // ===== Error Path Tests =====
1075
1076    #[test]
1077    fn test_parse_unknown_type_byte() {
1078        let data: &[u8] = &[
1079            0x00, b't', b'e', b's', b't', 0x00, 0xFF, // Invalid type byte
1080            b'k', b'e', b'y', 0x00,
1081        ];
1082        assert!(matches!(
1083            parse_shortcuts(data),
1084            Err(Error::UnknownType {
1085                type_byte: 0xFF,
1086                ..
1087            })
1088        ));
1089    }
1090
1091    #[test]
1092    fn test_parse_truncated_object_start() {
1093        let data: &[u8] = &[0x00]; // Incomplete object start
1094        assert!(matches!(
1095            parse_shortcuts(data),
1096            Err(Error::UnexpectedEndOfInput { .. })
1097        ));
1098    }
1099
1100    #[test]
1101    fn test_parse_truncated_string_value() {
1102        let data: &[u8] = &[
1103            0x00, b't', b'e', b's', b't', 0x00, 0x01, // String type
1104            b'k', b'e', b'y', 0x00,
1105            // Missing null terminator
1106        ];
1107        assert!(matches!(
1108            parse_shortcuts(data),
1109            Err(Error::UnexpectedEndOfInput { .. })
1110        ));
1111    }
1112
1113    #[test]
1114    fn test_parse_invalid_utf8_string_strict() {
1115        let data: &[u8] = &[
1116            0x00, b't', b'e', b's', b't', 0x00, 0x01, // String type
1117            b'k', b'e', b'y', 0x00, 0xFF, 0xFF, 0x00, // Invalid UTF-8 followed by null
1118        ];
1119        let opts = ParseOptions::new().invalid_utf8(InvalidUtf8::Strict);
1120        assert!(matches!(
1121            parse_shortcuts_with(data, opts),
1122            Err(Error::InvalidUtf8 { .. })
1123        ));
1124    }
1125
1126    #[test]
1127    fn test_parse_invalid_utf8_string_lossy_default() {
1128        // The reported real-world case: a value with a lone 0xFD byte
1129        // ("Moje Spore v\u{fffd}tvory", Windows-1250 'ý'). The default is lossy,
1130        // so parsing succeeds and the bad byte becomes U+FFFD.
1131        let data: &[u8] = &[
1132            0x00, b't', b'e', b's', b't', 0x00, 0x01, // String type
1133            b'n', b'a', b'm', b'e', 0x00, // key "name"
1134            b'v', 0xFD, b't', 0x00, // value "v\u{fffd}t"
1135            0x08, // object end
1136        ];
1137        let vdf = parse_shortcuts(data).unwrap();
1138        let obj = vdf.as_obj().unwrap();
1139        let test = obj.get("test").and_then(|v| v.as_obj()).unwrap();
1140        assert_eq!(
1141            test.get("name").and_then(|v| v.as_str()),
1142            Some("v\u{fffd}t")
1143        );
1144    }
1145
1146    #[test]
1147    fn test_parse_invalid_utf8_key_lossy() {
1148        // Keys can also carry invalid bytes; lossy mode must handle them too.
1149        let data: &[u8] = &[
1150            0x00, b'k', 0xFD, 0x00, // object key "k\u{fffd}"
1151            0x01, b'a', 0x00, b'b', 0x00, // "a" -> "b"
1152            0x08,
1153        ];
1154        let vdf = parse_shortcuts(data).unwrap();
1155        let obj = vdf.as_obj().unwrap();
1156        assert!(obj.get("k\u{fffd}").is_some());
1157    }
1158
1159    #[test]
1160    fn test_valid_utf8_still_borrowed_lossy() {
1161        // Lossy mode must not allocate for valid strings — they stay borrowed.
1162        let data: &[u8] = &[
1163            0x00, b't', 0x00, // object "t"
1164            0x01, b'k', 0x00, b'v', 0x00, // "k" -> "v"
1165            0x08,
1166        ];
1167        let vdf = parse_shortcuts(data).unwrap();
1168        let obj = vdf.as_obj().unwrap();
1169        let inner = obj.get("t").and_then(|v| v.as_obj()).unwrap();
1170        match inner.get("k").unwrap() {
1171            Value::Str(Cow::Borrowed(_)) => {}
1172            other => panic!("expected borrowed str, got {other:?}"),
1173        }
1174    }
1175
1176    #[test]
1177    fn test_parse_truncated_int32_value() {
1178        let data: &[u8] = &[
1179            0x00, b't', b'e', b's', b't', 0x00, 0x02, // Int32 type
1180            b'k', b'e', b'y', 0x00, 0x01, 0x02, // Only 2 bytes instead of 4
1181        ];
1182        assert!(matches!(
1183            parse_shortcuts(data),
1184            Err(Error::UnexpectedEndOfInput { .. })
1185        ));
1186    }
1187
1188    #[test]
1189    fn test_parse_truncated_uint64_value() {
1190        let data: &[u8] = &[
1191            0x00, b't', b'e', b's', b't', 0x00, 0x07, // UInt64 type
1192            b'k', b'e', b'y', 0x00, 0x01, 0x02, 0x03, 0x04, // Only 4 bytes instead of 8
1193        ];
1194        assert!(matches!(
1195            parse_shortcuts(data),
1196            Err(Error::UnexpectedEndOfInput { .. })
1197        ));
1198    }
1199
1200    #[test]
1201    fn test_parse_truncated_float_value() {
1202        let data: &[u8] = &[
1203            0x00, b't', b'e', b's', b't', 0x00, 0x03, // Float type
1204            b'k', b'e', b'y', 0x00, 0x01, 0x02, // Only 2 bytes instead of 4
1205        ];
1206        assert!(matches!(
1207            parse_shortcuts(data),
1208            Err(Error::UnexpectedEndOfInput { .. })
1209        ));
1210    }
1211
1212    #[test]
1213    fn test_parse_truncated_color_value() {
1214        let data: &[u8] = &[
1215            0x00, b't', b'e', b's', b't', 0x00, 0x06, // Color type
1216            b'k', b'e', b'y', 0x00, 0xFF, 0x00, // Only 2 bytes instead of 4
1217        ];
1218        assert!(matches!(
1219            parse_shortcuts(data),
1220            Err(Error::UnexpectedEndOfInput { .. })
1221        ));
1222    }
1223
1224    #[test]
1225    fn test_parse_wstring_unpaired_surrogate() {
1226        // WideString with unpaired surrogate - Rust's decode_utf16 replaces with
1227        // replacement character rather than erroring, so this should parse successfully
1228        let data: &[u8] = &[
1229            0x00, b't', b'e', b's', b't', 0x00, 0x05, // WideString type
1230            b'k', b'e', b'y', 0x00, 0xD8, 0x00, 0x00,
1231            0x00, // Unpaired surrogate (UTF-16) - gets replaced
1232        ];
1233        assert!(parse_shortcuts(data).is_ok());
1234    }
1235
1236    #[test]
1237    fn test_parse_appinfo_invalid_magic() {
1238        let data: &[u8] = &[
1239            0xDE, 0xAD, 0xBE, 0xEF, // Invalid magic
1240            0x00, 0x00, 0x00, 0x00, // universe
1241            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // padding to meet minimum size
1242            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1243        ];
1244        assert!(matches!(
1245            parse_appinfo(data),
1246            Err(Error::InvalidMagic { .. })
1247        ));
1248    }
1249
1250    #[test]
1251    fn test_parse_appinfo_truncated_header() {
1252        let data: &[u8] = &[
1253            0x28, 0x44, 0x56, 0x07, // APPINFO_MAGIC_41 first byte
1254        ];
1255        assert!(matches!(
1256            parse_appinfo(data),
1257            Err(Error::UnexpectedEndOfInput { .. })
1258        ));
1259    }
1260
1261    #[test]
1262    fn test_parse_appinfo_v41_invalid_string_table_offset() {
1263        // v41 with string table offset beyond file length
1264        let data: &[u8] = &[
1265            0x28, 0x44, 0x56, 0x07, // APPINFO_MAGIC_41
1266            0x00, 0x00, 0x00, 0x00, // universe
1267            0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1268            0x00, // string table offset (255, beyond EOF)
1269        ];
1270        assert!(matches!(
1271            parse_appinfo(data),
1272            Err(Error::UnexpectedEndOfInput { .. })
1273        ));
1274    }
1275
1276    /// Build a minimal, well-formed v41 appinfo blob with a single app whose
1277    /// only key is string-table entry 0. The caller supplies the raw bytes of
1278    /// that entry (without the null terminator), so a test can inject invalid
1279    /// UTF-8 into the *string table* (the path a v41 localized key takes).
1280    fn build_v41_appinfo(key_bytes: &[u8]) -> alloc::vec::Vec<u8> {
1281        let mut data = alloc::vec::Vec::new();
1282        data.extend_from_slice(&APPINFO_MAGIC_41.to_le_bytes());
1283        data.extend_from_slice(&1u32.to_le_bytes()); // universe
1284        let st_offset_pos = data.len();
1285        data.extend_from_slice(&[0u8; 8]); // string table offset (patched later)
1286
1287        // --- one app entry ---
1288        data.extend_from_slice(&10u32.to_le_bytes()); // app_id
1289        let size_pos = data.len();
1290        data.extend_from_slice(&[0u8; 4]); // size (patched later)
1291        let after_size_start = data.len();
1292        data.extend_from_slice(&[0u8; APPINFO_HEADER_AFTER_SIZE]); // 60-byte header tail
1293        // VDF: string entry with key = table index 0, inline value "value"
1294        data.push(0x01); // String type
1295        data.extend_from_slice(&0u32.to_le_bytes()); // key index 0
1296        data.extend_from_slice(b"value\x00");
1297        data.push(0x08); // object end
1298        let size = (data.len() - after_size_start) as u32;
1299        data[size_pos..size_pos + 4].copy_from_slice(&size.to_le_bytes());
1300
1301        // In v41 the apps section ends at the string-table offset (there is no
1302        // app_id=0 terminator), so the table immediately follows the last app.
1303        // --- string table ---
1304        let st_offset = data.len() as u64;
1305        data[st_offset_pos..st_offset_pos + 8].copy_from_slice(&st_offset.to_le_bytes());
1306        data.extend_from_slice(&1u32.to_le_bytes()); // string_count
1307        data.extend_from_slice(key_bytes);
1308        data.push(0x00); // null terminator
1309
1310        data
1311    }
1312
1313    #[test]
1314    fn test_parse_appinfo_v41_invalid_utf8_string_table_lossy() {
1315        // Invalid byte (0xFD) inside a v41 string-table entry used as a key.
1316        let data = build_v41_appinfo(&[b'n', b'a', 0xFD, b'e']);
1317        let vdf = parse_appinfo(&data).unwrap();
1318        let obj = vdf.as_obj().unwrap();
1319        let app = obj.get("10").and_then(|v| v.as_obj()).unwrap();
1320        // Key was decoded lossily; the value is still reachable under it.
1321        assert_eq!(
1322            app.get("na\u{fffd}e").and_then(|v| v.as_str()),
1323            Some("value")
1324        );
1325    }
1326
1327    #[test]
1328    fn test_parse_appinfo_v41_invalid_utf8_string_table_strict() {
1329        let data = build_v41_appinfo(&[b'n', b'a', 0xFD, b'e']);
1330        let opts = ParseOptions::new().invalid_utf8(InvalidUtf8::Strict);
1331        assert!(matches!(
1332            parse_appinfo_with(&data, opts),
1333            Err(Error::InvalidUtf8 { .. })
1334        ));
1335    }
1336
1337    #[test]
1338    fn test_parse_appinfo_v41_valid_string_table_roundtrips() {
1339        // Sanity check that the synthetic v41 builder produces a parseable blob.
1340        let data = build_v41_appinfo(b"name");
1341        let vdf = parse_appinfo(&data).unwrap();
1342        let obj = vdf.as_obj().unwrap();
1343        let app = obj.get("10").and_then(|v| v.as_obj()).unwrap();
1344        assert_eq!(app.get("name").and_then(|v| v.as_str()), Some("value"));
1345    }
1346
1347    #[test]
1348    fn test_parse_packageinfo_invalid_magic() {
1349        let data: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x00];
1350        assert!(matches!(
1351            parse_packageinfo(data),
1352            Err(Error::InvalidMagic { .. })
1353        ));
1354    }
1355
1356    #[test]
1357    fn test_parse_packageinfo_truncated_header() {
1358        let data: &[u8] = &[0x27]; // Partial magic
1359        assert!(matches!(
1360            parse_packageinfo(data),
1361            Err(Error::UnexpectedEndOfInput { .. })
1362        ));
1363    }
1364
1365    #[test]
1366    fn test_parse_appinfo_with_terminator() {
1367        // v40 format with immediate app_id terminator (no apps)
1368        // Need 68 bytes minimum (APPINFO_ENTRY_HEADER_SIZE) to pass the size check
1369        let data: &[u8] = &[
1370            0x28, 0x44, 0x56, 0x07, // APPINFO_MAGIC_40
1371            0x00, 0x00, 0x00, 0x00, // universe
1372            0x00, 0x00, 0x00, 0x00, // app_id = 0 (terminator)
1373            // Padding to meet APPINFO_ENTRY_HEADER_SIZE (68 bytes total)
1374            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1375            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1376            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1377            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1378            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1379            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1380            0x00, 0x00, 0x00, 0x00,
1381        ];
1382        let result = parse_appinfo(data);
1383        if let Err(e) = &result {
1384            panic!("parse_appinfo failed with: {:?}", e);
1385        }
1386        assert!(
1387            result.is_ok(),
1388            "Appinfo with terminator should parse successfully"
1389        );
1390        let vdf = result.unwrap();
1391        let obj = vdf.as_obj().unwrap();
1392        assert_eq!(obj.len(), 0, "Should have no apps");
1393    }
1394
1395    #[test]
1396    fn test_parse_autodetect_fallback_to_shortcuts() {
1397        // Data that doesn't look like appinfo should be parsed as shortcuts
1398        let data: &[u8] = &[
1399            0x00, // Object start (not appinfo magic)
1400            b't', b'e', b's', b't', 0x00, 0x01, // String type
1401            b'k', b'e', b'y', 0x00, b'v', b'a', b'l', b'u', b'e', 0x00, 0x08, // Object end
1402        ];
1403        let result = parse(data);
1404        assert!(result.is_ok());
1405    }
1406
1407    // ===== packageinfo tests =====
1408
1409    #[test]
1410    fn test_parse_packageinfo_v39_invalid_magic_base() {
1411        // Correct version (39 = 0x27) but wrong magic base
1412        let data: &[u8] = &[
1413            0x27, 0xBE, 0xBA, 0xFE, // Wrong magic base (0xFEBAFE instead of 0x065655)
1414            0x00, 0x00, 0x00, 0x00, // universe
1415        ];
1416        assert!(matches!(
1417            parse_packageinfo(data),
1418            Err(Error::InvalidMagic { .. })
1419        ));
1420    }
1421
1422    #[test]
1423    fn test_parse_packageinfo_invalid_version() {
1424        // Correct magic base but wrong version (38 instead of 39/40)
1425        // 0x06565526 = version 38
1426        let data: &[u8] = &[
1427            0x26, 0x55, 0x56, 0x06, // magic with version 38
1428            0x00, 0x00, 0x00, 0x00, // universe
1429        ];
1430        assert!(matches!(
1431            parse_packageinfo(data),
1432            Err(Error::InvalidMagic { .. })
1433        ));
1434    }
1435
1436    #[test]
1437    fn test_parse_packageinfo_v39_truncated_universe() {
1438        // v39 magic but missing universe bytes
1439        let data: &[u8] = &[
1440            0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39
1441            0x00, 0x00, // incomplete universe (only 2 bytes)
1442        ];
1443        assert!(matches!(
1444            parse_packageinfo(data),
1445            Err(Error::UnexpectedEndOfInput { .. })
1446        ));
1447    }
1448
1449    #[test]
1450    fn test_parse_packageinfo_v39_with_terminator() {
1451        // v39 format with immediate termination marker (no packages)
1452        let data: &[u8] = &[
1453            0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39 (v39)
1454            0x01, 0x00, 0x00, 0x00, // universe = 1
1455            0xFF, 0xFF, 0xFF, 0xFF, // package_id = 0xFFFFFFFF (terminator)
1456        ];
1457        let result = parse_packageinfo(data);
1458        assert!(
1459            result.is_ok(),
1460            "parse_packageinfo failed: {:?}",
1461            result.err()
1462        );
1463        let vdf = result.unwrap();
1464        assert_eq!(vdf.key(), "packageinfo_universe_1");
1465        let obj = vdf.as_obj().unwrap();
1466        assert_eq!(obj.len(), 0, "Should have no packages");
1467    }
1468
1469    #[test]
1470    fn test_parse_packageinfo_v40_with_terminator() {
1471        // v40 format with immediate termination marker (no packages)
1472        let data: &[u8] = &[
1473            0x28, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_40 (v40)
1474            0x01, 0x00, 0x00, 0x00, // universe = 1
1475            0xFF, 0xFF, 0xFF, 0xFF, // package_id = 0xFFFFFFFF (terminator)
1476        ];
1477        let result = parse_packageinfo(data);
1478        assert!(
1479            result.is_ok(),
1480            "parse_packageinfo failed: {:?}",
1481            result.err()
1482        );
1483        let vdf = result.unwrap();
1484        assert_eq!(vdf.key(), "packageinfo_universe_1");
1485        let obj = vdf.as_obj().unwrap();
1486        assert_eq!(obj.len(), 0, "Should have no packages");
1487    }
1488
1489    #[test]
1490    fn test_parse_packageinfo_v39_truncated_entry_header() {
1491        // v39 format with package_id but incomplete header
1492        // Header size for v39 is 4 + 20 + 4 = 28 bytes (package_id + hash + change_number)
1493        let data: &[u8] = &[
1494            0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39
1495            0x00, 0x00, 0x00, 0x00, // universe
1496            0x01, 0x00, 0x00, 0x00, // package_id = 1
1497            // Only 10 bytes of hash (need 20), missing change_number
1498            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
1499        ];
1500        assert!(matches!(
1501            parse_packageinfo(data),
1502            Err(Error::UnexpectedEndOfInput { context, .. }) if context == "reading package entry header"
1503        ));
1504    }
1505
1506    #[test]
1507    fn test_parse_packageinfo_v40_truncated_entry_header() {
1508        // v40 format with package_id but incomplete header
1509        // Header size for v40 is 4 + 20 + 4 + 8 = 36 bytes (+ token)
1510        let data: &[u8] = &[
1511            0x28, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_40
1512            0x00, 0x00, 0x00, 0x00, // universe
1513            0x01, 0x00, 0x00, 0x00, // package_id = 1
1514            // Only hash (20 bytes), missing change_number and token
1515            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x01, 0x02, 0x03, 0x04,
1516            0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
1517        ];
1518        assert!(matches!(
1519            parse_packageinfo(data),
1520            Err(Error::UnexpectedEndOfInput { context, .. }) if context == "reading package entry header"
1521        ));
1522    }
1523
1524    #[test]
1525    fn test_parse_packageinfo_v39_with_minimal_vdf() {
1526        // v39 format with minimal VDF that tests basic parsing
1527        let data: &[u8] = &[
1528            0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39
1529            0x00, 0x00, 0x00, 0x00, // universe = 0
1530            // Package entry
1531            0x01, 0x00, 0x00, 0x00, // package_id = 1
1532            // SHA-1 hash (20 bytes)
1533            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1534            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A, 0x00, 0x00, 0x00, // change_number = 42
1535            // VDF: simple object with one string entry { "k": "value" }
1536            0x01, // String type
1537            b'k', 0x00, // Key "k"
1538            b'v', b'a', b'l', b'u', b'e', 0x00, // Value "value"
1539            0x08, // Object end
1540            // Termination marker
1541            0xFF, 0xFF, 0xFF, 0xFF,
1542        ];
1543        let result = parse_packageinfo(data);
1544        assert!(
1545            result.is_ok(),
1546            "parse_packageinfo failed: {:?}",
1547            result.err()
1548        );
1549        let vdf = result.unwrap();
1550        assert_eq!(vdf.key(), "packageinfo_universe_0");
1551
1552        let obj = vdf.as_obj().unwrap();
1553        assert_eq!(obj.len(), 1);
1554        let package = obj.get("1").and_then(|v| v.as_obj()).unwrap();
1555        assert_eq!(package.get("k").and_then(|v| v.as_str()), Some("value"));
1556    }
1557
1558    #[test]
1559    fn test_parse_packageinfo_v40_with_minimal_vdf() {
1560        // v40 format with minimal VDF
1561        let data: &[u8] = &[
1562            0x28, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_40
1563            0x00, 0x00, 0x00, 0x00, // universe = 0
1564            // Package entry
1565            0x01, 0x00, 0x00, 0x00, // package_id = 1
1566            // SHA-1 hash (20 bytes)
1567            0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
1568            0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0x2A, 0x00, 0x00, 0x00, // change_number = 42
1569            // PICS token (8 bytes)
1570            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
1571            // VDF: simple object with one int32 entry { "x": 5 }
1572            0x02, // Int32 type
1573            b'x', 0x00, // Key "x"
1574            0x05, 0x00, 0x00, 0x00, // Value 5
1575            0x08, // Object end
1576            // Termination marker
1577            0xFF, 0xFF, 0xFF, 0xFF,
1578        ];
1579        let result = parse_packageinfo(data);
1580        assert!(
1581            result.is_ok(),
1582            "parse_packageinfo failed: {:?}",
1583            result.err()
1584        );
1585        let vdf = result.unwrap();
1586        assert_eq!(vdf.key(), "packageinfo_universe_0");
1587
1588        let obj = vdf.as_obj().unwrap();
1589        assert_eq!(obj.len(), 1);
1590        let package = obj.get("1").and_then(|v| v.as_obj()).unwrap();
1591        assert_eq!(package.get("x").and_then(|v| v.as_i32()), Some(5));
1592    }
1593
1594    #[test]
1595    fn test_parse_packageinfo_multiple_packages() {
1596        // v39 format with multiple packages
1597        let data: &[u8] = &[
1598            0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39
1599            0x00, 0x00, 0x00, 0x00, // universe = 0
1600            // First package
1601            0x01, 0x00, 0x00, 0x00, // package_id = 1
1602            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1603            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // hash
1604            0x01, 0x00, 0x00, 0x00, // change_number = 1
1605            // VDF: { "x": 1 }
1606            0x02, 0x01, 0x00, 0x00, 0x00, b'x', 0x00, 0x08, // Second package
1607            0x02, 0x00, 0x00, 0x00, // package_id = 2
1608            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1609            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // hash
1610            0x02, 0x00, 0x00, 0x00, // change_number = 2
1611            // VDF: { "a": 2 }
1612            0x02, 0x02, 0x00, 0x00, 0x00, b'a', 0x00, 0x08, // Termination marker
1613            0xFF, 0xFF, 0xFF, 0xFF,
1614        ];
1615        let result = parse_packageinfo(data);
1616        assert!(
1617            result.is_ok(),
1618            "parse_packageinfo failed: {:?}",
1619            result.err()
1620        );
1621        let vdf = result.unwrap();
1622        let obj = vdf.as_obj().unwrap();
1623        assert_eq!(obj.len(), 2);
1624        assert!(obj.get("1").is_some());
1625        assert!(obj.get("2").is_some());
1626    }
1627
1628    #[test]
1629    fn test_parse_packageinfo_empty_input() {
1630        // Completely empty input
1631        let data: &[u8] = &[];
1632        assert!(matches!(
1633            parse_packageinfo(data),
1634            Err(Error::UnexpectedEndOfInput { context, .. }) if context == "reading packageinfo header"
1635        ));
1636    }
1637
1638    #[test]
1639    fn test_parse_packageinfo_only_magic() {
1640        // Only magic bytes, no universe
1641        let data: &[u8] = &[
1642            0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39
1643        ];
1644        assert!(matches!(
1645            parse_packageinfo(data),
1646            Err(Error::UnexpectedEndOfInput { .. })
1647        ));
1648    }
1649}