Skip to main content

unity_formats/
bundle.rs

1//! UnityFS bundles: the archive AssetBundles, Addressables content and a player's `data.unity3d` are.
2//!
3//! Layout (format version 8, header flags `0x243`; every number big-endian): the signature `UnityFS\0`, the format
4//! version, the player version and the engine version (NUL-terminated), the bundle's size (`u64`), the compressed and
5//! the uncompressed size of the *block table* (`u32` each) and the header flags; then, aligned to 16 bytes, the block
6//! table, compressed as the low six bits of the flags say (0 none, 2 LZ4, 3 LZ4HC), and, when flag `0x200` is set,
7//! padding to 16 bytes; then the blocks. The decompressed block table holds a 16-byte hash, the blocks (`u32`
8//! uncompressed size, `u32` compressed size, `u16` flags whose low six bits are the block's compression) and the nodes:
9//! the files in the blocks' concatenated bytes (`i64` offset, `i64` size, `u32` flags, a NUL-terminated path; flag `4`
10//! marks a SerializedFile, `0` a stream file such as `.resS`).
11//!
12//! What it reads, from the bundles of PEAK, Sledding Game and Big Walk: format 8, flags `0x243`, blocks stored or
13//! LZ4HC of at most 128 KiB. What it refuses, as [`Error::Unsupported`]: another format version, header flags other
14//! than those (the block table at the end of the file, a table not stored with the directory), a LZMA or LZHAM
15//! compressed block table or block, and compression numbers it does not know. A bundle whose sizes do not add up is
16//! [`Error::Invalid`]. A node is read by range: only the blocks it touches are decompressed, a few recent blocks are
17//! kept, so a node of a gigabyte costs its reader nothing until it asks for bytes.
18#![allow(
19    clippy::disallowed_methods,
20    clippy::disallowed_types,
21    reason = "reader I/O: reads bundle files"
22)]
23
24use std::fmt;
25use std::io::{Read, Seek, SeekFrom};
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, PoisonError};
28
29use primase_foundations::compress::{Decompressor, Lz4};
30
31use crate::reader::Reader;
32use crate::{Error, Result, invalid};
33
34const MAGIC: &[u8; 8] = b"UnityFS\0";
35
36/// The format version read.
37pub const FORMAT_VERSION: u32 = 8;
38
39/// Block table flags: the low six bits name its compression.
40const COMPRESSION_MASK: u32 = 0x3f;
41/// The block table is stored with the directory (the only layout read).
42const TABLE_WITH_DIRECTORY: u32 = 0x40;
43/// The block table is at the end of the file instead of after the header (refused).
44const TABLE_AT_END: u32 = 0x80;
45/// The blocks start at the next multiple of 16 after the table.
46const PAD_BEFORE_DATA: u32 = 0x200;
47
48/// Node flag: the node is a SerializedFile.
49pub const NODE_SERIALIZED: u32 = 4;
50
51/// How many decompressed blocks a [`Bundle`] keeps for ranged reads.
52const CACHE_BLOCKS: usize = 16;
53
54/// The fixed fields of a bundle's header, as stored.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct BundleHeader {
57    /// The format version (8).
58    pub format_version: u32,
59    /// The player version string (`5.x.x` in every bundle read).
60    pub player_version: String,
61    /// The engine version the bundle was built with (`6000.3.15f1`).
62    pub engine_version: String,
63    /// The bundle's size in bytes; equal to the file's length.
64    pub size: u64,
65    /// The compressed size of the block table.
66    pub table_compressed: u32,
67    /// The uncompressed size of the block table.
68    pub table_uncompressed: u32,
69    /// The header flags (`0x243`).
70    pub flags: u32,
71}
72
73/// One block of the bundle's data.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub struct Block {
76    /// The block's size once decompressed.
77    pub uncompressed: u32,
78    /// The block's size as stored.
79    pub compressed: u32,
80    /// The block's flags; the low six bits are its compression (0 stored, 2 LZ4, 3 LZ4HC).
81    pub flags: u16,
82}
83
84/// One file in a bundle.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct BundleNode {
87    /// The file's path in the bundle (`BuildPlayer-Level_5.sharedAssets`, `Resources/unity_builtin_extra`).
88    pub path: String,
89    /// Where the file starts in the blocks' decompressed bytes.
90    pub offset: u64,
91    /// The file's size in bytes.
92    pub size: u64,
93    /// The node's flags: [`NODE_SERIALIZED`] (4) for a SerializedFile, 0 for a stream file.
94    pub flags: u32,
95}
96
97impl BundleNode {
98    /// Whether the node is a SerializedFile (its flags say so).
99    pub fn is_serialized(&self) -> bool {
100        self.flags & NODE_SERIALIZED != 0
101    }
102}
103
104/// What a bundle's bytes come from: a file, or any reader that can seek.
105pub trait BundleSource: Read + Seek + Send {}
106impl<T: Read + Seek + Send> BundleSource for T {}
107
108/// A block's place: where its decompressed bytes start in the data, and where its stored bytes start in the file.
109#[derive(Clone, Copy, Debug)]
110struct Placed {
111    block: Block,
112    data_start: u64,
113    file_start: u64,
114}
115
116/// An open UnityFS bundle: its header, blocks and nodes, with the file kept open to read nodes by range.
117pub struct Bundle {
118    path: PathBuf,
119    header: BundleHeader,
120    hash: [u8; 16],
121    nodes: Vec<BundleNode>,
122    blocks: Vec<Placed>,
123    data_len: u64,
124    source: Mutex<Box<dyn BundleSource>>,
125    recent: Mutex<Vec<(usize, Arc<[u8]>)>>,
126}
127
128impl fmt::Debug for Bundle {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.debug_struct("Bundle")
131            .field("path", &self.path)
132            .field("header", &self.header)
133            .field("nodes", &self.nodes.len())
134            .field("blocks", &self.blocks.len())
135            .finish()
136    }
137}
138
139fn unsupported_compression(what: &str, code: u32) -> Error {
140    let name = match code {
141        1 => "LZMA",
142        4 => "LZHAM",
143        _ => "an unknown compression",
144    };
145    Error::Unsupported(format!(
146        "{what} compressed with {name} (compression {code}); stored, LZ4 and LZ4HC are read"
147    ))
148}
149
150/// `bytes` decompressed as `compression` says to `expected` bytes.
151fn inflate(what: &str, compression: u32, bytes: &[u8], expected: usize) -> Result<Vec<u8>> {
152    match compression {
153        0 => {
154            if bytes.len() == expected {
155                Ok(bytes.to_vec())
156            } else {
157                Err(invalid(format!(
158                    "{what} is stored with {} bytes, its size is {expected}",
159                    bytes.len()
160                )))
161            }
162        }
163        2 | 3 => Lz4
164            .decompress(bytes, expected)
165            .map_err(|e| invalid(format!("{what}: LZ4: {e}"))),
166        c => Err(unsupported_compression(what, c)),
167    }
168}
169
170impl Bundle {
171    /// Open the bundle at `path`: read its header and block table and check that the sizes add up (the header's size is
172    /// the file's, the blocks end where the file does, every node lies in the blocks' bytes). Reads nothing of the data.
173    pub fn open(path: &Path) -> Result<Self> {
174        let io = |source| Error::Io {
175            path: path.to_path_buf(),
176            source,
177        };
178        let file = std::fs::File::open(path).map_err(io)?;
179        Self::from_source(path, Box::new(file))
180    }
181
182    /// As [`Bundle::open`], on any seekable `source` (`path` only names it in errors).
183    pub fn from_source(path: &Path, mut source: Box<dyn BundleSource>) -> Result<Self> {
184        let named = |e: Error| match e {
185            Error::Invalid(m) => invalid(format!("{}: {m}", path.display())),
186            Error::Unsupported(m) => Error::Unsupported(format!("{}: {m}", path.display())),
187            other => other,
188        };
189        Self::parse(path, &mut source)
190            .map_err(named)
191            .map(|parsed| Bundle {
192                path: path.to_path_buf(),
193                header: parsed.header,
194                hash: parsed.hash,
195                nodes: parsed.nodes,
196                blocks: parsed.blocks,
197                data_len: parsed.data_len,
198                source: Mutex::new(source),
199                recent: Mutex::new(Vec::new()),
200            })
201    }
202
203    fn parse(path: &Path, source: &mut Box<dyn BundleSource>) -> Result<Parsed> {
204        let io = |source| Error::Io {
205            path: path.to_path_buf(),
206            source,
207        };
208        let len = source.seek(SeekFrom::End(0)).map_err(io)?;
209        source.seek(SeekFrom::Start(0)).map_err(io)?;
210        // the header up to its flags: two short strings and 28 bytes of numbers
211        let mut head = Vec::new();
212        source.by_ref().take(1024).read_to_end(&mut head).map_err(io)?;
213        let mut r = Reader::new(&head, true);
214        if r.take(MAGIC.len()).ok() != Some(MAGIC.as_slice()) {
215            return Err(invalid("not a UnityFS bundle (signature)"));
216        }
217        let format_version = r.u32()?;
218        if format_version != FORMAT_VERSION {
219            return Err(Error::Unsupported(format!(
220                "UnityFS format version {format_version} (read: {FORMAT_VERSION})"
221            )));
222        }
223        let player_version = r.cstr()?;
224        let engine_version = r.cstr()?;
225        let size = r.u64()?;
226        let table_compressed = r.u32()?;
227        let table_uncompressed = r.u32()?;
228        let flags = r.u32()?;
229        let header = BundleHeader {
230            format_version,
231            player_version,
232            engine_version,
233            size,
234            table_compressed,
235            table_uncompressed,
236            flags,
237        };
238        if size != len {
239            return Err(invalid(format!(
240                "the header says {size} bytes, the file has {len}"
241            )));
242        }
243        let known = COMPRESSION_MASK | TABLE_WITH_DIRECTORY | TABLE_AT_END | PAD_BEFORE_DATA;
244        if flags & !known != 0 {
245            return Err(Error::Unsupported(format!(
246                "UnityFS header flags {flags:#x} (read: {TABLE_WITH_DIRECTORY:#x} plus a compression, with or without \
247                 {PAD_BEFORE_DATA:#x})"
248            )));
249        }
250        if flags & TABLE_AT_END != 0 {
251            return Err(Error::Unsupported(format!(
252                "UnityFS header flags {flags:#x}: the block table at the end of the file (no bundle read has it)"
253            )));
254        }
255        if flags & TABLE_WITH_DIRECTORY == 0 {
256            return Err(Error::Unsupported(format!(
257                "UnityFS header flags {flags:#x}: the block table not stored with the directory"
258            )));
259        }
260        // after the header the table starts at the next multiple of 16 (format 7 and later)
261        let table_at = u64::try_from(r.pos)
262            .ok()
263            .and_then(|p| p.checked_next_multiple_of(16))
264            .ok_or_else(|| invalid("header too long"))?;
265        let table_end = table_at
266            .checked_add(u64::from(table_compressed))
267            .filter(|&e| e <= len)
268            .ok_or_else(|| {
269                invalid(format!(
270                    "the block table ({table_compressed} bytes at {table_at}) is past the file's {len} bytes"
271                ))
272            })?;
273        source.seek(SeekFrom::Start(table_at)).map_err(io)?;
274        let mut packed = vec![0; usize::try_from(table_compressed).map_err(|_| invalid("block table size"))?];
275        source.read_exact(&mut packed).map_err(io)?;
276        let table = inflate(
277            "the block table",
278            flags & COMPRESSION_MASK,
279            &packed,
280            usize::try_from(table_uncompressed).map_err(|_| invalid("block table size"))?,
281        )?;
282        let mut t = Reader::new(&table, true);
283        let hash: [u8; 16] = t
284            .take(16)?
285            .try_into()
286            .map_err(|_| invalid("the block table's hash"))?;
287        let block_count = t.i32()?;
288        let block_count = usize::try_from(block_count)
289            .ok()
290            .filter(|&n| n <= t.remaining() / 10)
291            .ok_or_else(|| {
292                invalid(format!(
293                    "{block_count} blocks in a block table of {} bytes",
294                    table.len()
295                ))
296            })?;
297        let data_at = if flags & PAD_BEFORE_DATA != 0 {
298            table_end
299                .checked_next_multiple_of(16)
300                .ok_or_else(|| invalid("data offset"))?
301        } else {
302            table_end
303        };
304        let mut blocks = Vec::with_capacity(block_count);
305        let (mut data_len, mut file_at) = (0u64, data_at);
306        for i in 0..block_count {
307            let block = Block {
308                uncompressed: t.u32()?,
309                compressed: t.u32()?,
310                flags: t.u16()?,
311            };
312            let compression = u32::from(block.flags) & COMPRESSION_MASK;
313            if !matches!(compression, 0 | 2 | 3) {
314                return Err(unsupported_compression(&format!("block {i}"), compression));
315            }
316            if compression == 0 && block.compressed != block.uncompressed {
317                return Err(invalid(format!(
318                    "block {i} is stored with {} bytes, its size is {}",
319                    block.compressed, block.uncompressed
320                )));
321            }
322            blocks.push(Placed {
323                block,
324                data_start: data_len,
325                file_start: file_at,
326            });
327            data_len = data_len
328                .checked_add(u64::from(block.uncompressed))
329                .ok_or_else(|| invalid("blocks add up past 2^64 bytes"))?;
330            file_at = file_at
331                .checked_add(u64::from(block.compressed))
332                .ok_or_else(|| invalid("blocks add up past 2^64 bytes"))?;
333        }
334        if file_at != len {
335            return Err(invalid(format!(
336                "the blocks end at {file_at} (data at {data_at}), the file has {len} bytes"
337            )));
338        }
339        let node_count = t.i32()?;
340        let node_count = usize::try_from(node_count)
341            .ok()
342            .filter(|&n| n <= t.remaining() / 21)
343            .ok_or_else(|| invalid(format!("{node_count} nodes in {} bytes", t.remaining())))?;
344        let mut nodes: Vec<BundleNode> = Vec::with_capacity(node_count);
345        for i in 0..node_count {
346            let offset = t.i64()?;
347            let size = t.i64()?;
348            let flags = t.u32()?;
349            let path = t.cstr()?;
350            let (offset, size) = u64::try_from(offset)
351                .ok()
352                .zip(u64::try_from(size).ok())
353                .ok_or_else(|| invalid(format!("node {i} ({path}): offset {offset}, size {size}")))?;
354            if offset.checked_add(size).is_none_or(|end| end > data_len) {
355                return Err(invalid(format!(
356                    "node {i} ({path}): bytes {offset}+{size} are outside the blocks' {data_len} bytes"
357                )));
358            }
359            if path.is_empty() || nodes.iter().any(|n| n.path == path) {
360                return Err(invalid(format!(
361                    "node {i}: the path {path:?} is empty or twice in the bundle"
362                )));
363            }
364            nodes.push(BundleNode {
365                path,
366                offset,
367                size,
368                flags,
369            });
370        }
371        if t.remaining() != 0 {
372            return Err(invalid(format!(
373                "{} bytes after the nodes in the block table",
374                t.remaining()
375            )));
376        }
377        Ok(Parsed {
378            header,
379            hash,
380            nodes,
381            blocks,
382            data_len,
383        })
384    }
385
386    /// The file the bundle was opened from.
387    pub fn path(&self) -> &Path {
388        &self.path
389    }
390
391    /// The header as stored.
392    pub fn header(&self) -> &BundleHeader {
393        &self.header
394    }
395
396    /// The 16 bytes at the start of the block table (nothing reads them).
397    pub fn hash(&self) -> [u8; 16] {
398        self.hash
399    }
400
401    /// The files in the bundle, in the order the directory lists them.
402    pub fn nodes(&self) -> &[BundleNode] {
403        &self.nodes
404    }
405
406    /// The node whose path is exactly `path`.
407    pub fn node(&self, path: &str) -> Option<&BundleNode> {
408        self.nodes.iter().find(|n| n.path == path)
409    }
410
411    /// The blocks, in data order.
412    pub fn blocks(&self) -> impl ExactSizeIterator<Item = Block> + '_ {
413        self.blocks.iter().map(|p| p.block)
414    }
415
416    /// The size of all blocks once decompressed.
417    pub fn data_len(&self) -> u64 {
418        self.data_len
419    }
420
421    /// Block `i` decompressed.
422    fn inflate_block(&self, i: usize) -> Result<Vec<u8>> {
423        let placed = self
424            .blocks
425            .get(i)
426            .ok_or_else(|| invalid(format!("block {i} of {}", self.blocks.len())))?;
427        let mut packed =
428            vec![0; usize::try_from(placed.block.compressed).map_err(|_| invalid("block size"))?];
429        {
430            let io = |source| Error::Io {
431                path: self.path.clone(),
432                source,
433            };
434            let mut source = self.source.lock().unwrap_or_else(PoisonError::into_inner);
435            source.seek(SeekFrom::Start(placed.file_start)).map_err(io)?;
436            source.read_exact(&mut packed).map_err(io)?;
437        }
438        inflate(
439            &format!("{}: block {i}", self.path.display()),
440            u32::from(placed.block.flags) & COMPRESSION_MASK,
441            &packed,
442            usize::try_from(placed.block.uncompressed).map_err(|_| invalid("block size"))?,
443        )
444    }
445
446    /// Block `i` decompressed, from the recent blocks when it is one of them.
447    fn block(&self, i: usize) -> Result<Arc<[u8]>> {
448        let mut recent = self.recent.lock().unwrap_or_else(PoisonError::into_inner);
449        if let Some(at) = recent.iter().position(|(n, _)| *n == i) {
450            let hit = recent.remove(at);
451            let data = hit.1.clone();
452            recent.push(hit);
453            return Ok(data);
454        }
455        drop(recent);
456        let data: Arc<[u8]> = self.inflate_block(i)?.into();
457        let mut recent = self.recent.lock().unwrap_or_else(PoisonError::into_inner);
458        if recent.len() >= CACHE_BLOCKS {
459            recent.remove(0);
460        }
461        recent.push((i, data.clone()));
462        Ok(data)
463    }
464
465    /// `len` bytes of `node`, from `offset` bytes into it. Only the blocks the range touches are decompressed. A range
466    /// outside the node is an error.
467    pub fn read(&self, node: &BundleNode, offset: u64, len: usize) -> Result<Vec<u8>> {
468        let outside = || {
469            invalid(format!(
470                "{}: {}: bytes {offset}+{len} are outside the node's {} bytes",
471                self.path.display(),
472                node.path,
473                node.size
474            ))
475        };
476        let want = u64::try_from(len).map_err(|_| outside())?;
477        if offset.checked_add(want).is_none_or(|end| end > node.size) {
478            return Err(outside());
479        }
480        let start = node.offset.checked_add(offset).ok_or_else(outside)?;
481        let end = start.checked_add(want).ok_or_else(outside)?;
482        let mut out = Vec::new();
483        out.try_reserve_exact(len)
484            .map_err(|_| invalid(format!("{len} bytes do not fit memory")))?;
485        let mut i = self
486            .blocks
487            .partition_point(|p| p.data_start.saturating_add(u64::from(p.block.uncompressed)) <= start);
488        while out.len() < len {
489            let placed = self.blocks.get(i).ok_or_else(|| {
490                invalid(format!(
491                    "{}: ran out of blocks at {}",
492                    self.path.display(),
493                    out.len()
494                ))
495            })?;
496            let data = self.block(i)?;
497            let from = start.max(placed.data_start).saturating_sub(placed.data_start);
498            let to = end
499                .min(
500                    placed
501                        .data_start
502                        .saturating_add(u64::from(placed.block.uncompressed)),
503                )
504                .saturating_sub(placed.data_start);
505            let slice = usize::try_from(from)
506                .ok()
507                .zip(usize::try_from(to).ok())
508                .and_then(|(f, t)| data.get(f..t))
509                .ok_or_else(|| {
510                    invalid(format!(
511                        "{}: block {i} is shorter than its size",
512                        self.path.display()
513                    ))
514                })?;
515            out.extend_from_slice(slice);
516            i = i.saturating_add(1);
517        }
518        Ok(out)
519    }
520
521    /// All of `node`'s bytes. Blocks are decompressed one after the other and not kept.
522    pub fn read_node(&self, node: &BundleNode) -> Result<Vec<u8>> {
523        let len = usize::try_from(node.size)
524            .map_err(|_| invalid(format!("{}: {} bytes do not fit memory", node.path, node.size)))?;
525        let mut out = Vec::new();
526        out.try_reserve_exact(len)
527            .map_err(|_| invalid(format!("{}: {len} bytes do not fit memory", node.path)))?;
528        let start = node.offset;
529        let end = start
530            .checked_add(node.size)
531            .ok_or_else(|| invalid("node past 2^64 bytes"))?;
532        let first = self
533            .blocks
534            .partition_point(|p| p.data_start.saturating_add(u64::from(p.block.uncompressed)) <= start);
535        for i in first..self.blocks.len() {
536            let Some(placed) = self.blocks.get(i) else { break };
537            if placed.data_start >= end {
538                break;
539            }
540            let data = self.inflate_block(i)?;
541            let from = start.max(placed.data_start).saturating_sub(placed.data_start);
542            let to = end
543                .min(
544                    placed
545                        .data_start
546                        .saturating_add(u64::from(placed.block.uncompressed)),
547                )
548                .saturating_sub(placed.data_start);
549            let slice = usize::try_from(from)
550                .ok()
551                .zip(usize::try_from(to).ok())
552                .and_then(|(f, t)| data.get(f..t))
553                .ok_or_else(|| {
554                    invalid(format!(
555                        "{}: block {i} is shorter than its size",
556                        self.path.display()
557                    ))
558                })?;
559            out.extend_from_slice(slice);
560        }
561        if out.len() != len {
562            return Err(invalid(format!(
563                "{}: {} read {} of its {len} bytes",
564                self.path.display(),
565                node.path,
566                out.len()
567            )));
568        }
569        Ok(out)
570    }
571}
572
573/// What [`Bundle::parse`] reads, before the source is kept.
574struct Parsed {
575    header: BundleHeader,
576    hash: [u8; 16],
577    nodes: Vec<BundleNode>,
578    blocks: Vec<Placed>,
579    data_len: u64,
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use std::io::Cursor;
586
587    /// An LZ4 block of one literal run, encoded by hand.
588    fn literal_lz4(data: &[u8]) -> Vec<u8> {
589        assert!(data.len() >= 15);
590        let mut out = vec![0xf0];
591        let mut rest = data.len() - 15;
592        while rest >= 255 {
593            out.push(255);
594            rest -= 255;
595        }
596        out.push(u8::try_from(rest).unwrap());
597        out.extend(data);
598        out
599    }
600
601    /// A bundle of format 8 built the way the games' are: the table with the directory, padded to 16, data padded.
602    /// Each block is (bytes, compression).
603    fn build(
604        blocks: &[(&[u8], u32)],
605        nodes: &[(i64, i64, u32, &str)],
606        table_compression: u32,
607        flags: u32,
608    ) -> Vec<u8> {
609        let mut table = vec![7u8; 16];
610        table.extend((blocks.len() as i32).to_be_bytes());
611        let mut data = Vec::new();
612        for (bytes, compression) in blocks {
613            let stored = match compression {
614                0 => bytes.to_vec(),
615                _ => literal_lz4(bytes),
616            };
617            table.extend((bytes.len() as u32).to_be_bytes());
618            table.extend((stored.len() as u32).to_be_bytes());
619            table.extend((*compression as u16).to_be_bytes());
620            data.extend(stored);
621        }
622        table.extend((nodes.len() as i32).to_be_bytes());
623        for (offset, size, flags, path) in nodes {
624            table.extend(offset.to_be_bytes());
625            table.extend(size.to_be_bytes());
626            table.extend(flags.to_be_bytes());
627            table.extend(path.as_bytes());
628            table.push(0);
629        }
630        let packed = if table_compression == 0 {
631            table.clone()
632        } else {
633            literal_lz4(&table)
634        };
635        let mut out = b"UnityFS\0".to_vec();
636        out.extend(8u32.to_be_bytes());
637        out.extend(b"5.x.x\0");
638        out.extend(b"6000.3.15f1\0");
639        let size_at = out.len();
640        out.extend([0; 8]);
641        out.extend((packed.len() as u32).to_be_bytes());
642        out.extend((table.len() as u32).to_be_bytes());
643        out.extend(flags.to_be_bytes());
644        out.resize(out.len().next_multiple_of(16), 0);
645        out.extend(packed);
646        if flags & PAD_BEFORE_DATA != 0 {
647            out.resize(out.len().next_multiple_of(16), 0);
648        }
649        out.extend(data);
650        let total = out.len() as u64;
651        out[size_at..size_at + 8].copy_from_slice(&total.to_be_bytes());
652        out
653    }
654
655    fn open(bytes: Vec<u8>) -> Result<Bundle> {
656        Bundle::from_source(Path::new("test.bundle"), Box::new(Cursor::new(bytes)))
657    }
658
659    fn sample() -> Vec<u8> {
660        let a: Vec<u8> = (0..40u8).collect();
661        let b: Vec<u8> = (100..130u8).collect();
662        let c: Vec<u8> = (200..240u8).collect();
663        build(
664            &[(&a, 3), (&b, 0), (&c, 2)],
665            &[
666                (0, 25, 4, "first"),
667                (25, 70, 0, "Resources/second"),
668                (95, 15, 4, "third"),
669            ],
670            3,
671            0x243,
672        )
673    }
674
675    /// Nodes are read whole and by range across stored and LZ4 blocks, the same bytes either way.
676    #[test]
677    fn nodes_read_whole_and_by_range_across_blocks() {
678        let bundle = open(sample()).unwrap();
679        assert_eq!(bundle.header().engine_version, "6000.3.15f1");
680        assert_eq!(bundle.header().flags, 0x243);
681        assert_eq!(bundle.blocks().len(), 3);
682        assert_eq!(bundle.data_len(), 110);
683        let all: Vec<u8> = (0..40u8).chain(100..130).chain(200..240).collect();
684        for node in bundle.nodes() {
685            let (from, size) = (node.offset as usize, node.size as usize);
686            assert_eq!(
687                bundle.read_node(node).unwrap(),
688                all[from..from + size],
689                "{}",
690                node.path
691            );
692            assert_eq!(bundle.read(node, 0, size).unwrap(), all[from..from + size]);
693            for (offset, len) in [(0, 0), (1, size - 1), (size / 2, size - size / 2), (size, 0)] {
694                assert_eq!(
695                    bundle.read(node, offset as u64, len).unwrap(),
696                    all[from + offset..from + offset + len],
697                    "{} {offset}+{len}",
698                    node.path
699                );
700            }
701            assert!(bundle.read(node, 1, size).is_err());
702        }
703        assert!(bundle.node("Resources/second").unwrap().flags == 0);
704        assert!(bundle.node("third").unwrap().is_serialized());
705        assert!(bundle.node("missing").is_none());
706    }
707
708    /// A stored block table, and no padding before the data, read as well.
709    #[test]
710    fn a_stored_table_and_unpadded_data_are_read() {
711        let a: Vec<u8> = (0..40u8).collect();
712        let bytes = build(&[(&a, 3)], &[(5, 20, 4, "only")], 0, 0x40);
713        let bundle = open(bytes).unwrap();
714        assert_eq!(
715            bundle.read_node(bundle.node("only").unwrap()).unwrap(),
716            (5..25u8).collect::<Vec<_>>()
717        );
718    }
719
720    /// What the reader does not read is refused with the reason, and a damaged bundle with what is wrong.
721    #[test]
722    fn refusals_name_their_reason() {
723        let good = sample();
724        let with_flags = |flags: u32| {
725            let a: Vec<u8> = (0..40u8).collect();
726            open(build(&[(&a, 3)], &[(0, 10, 4, "n")], 3, flags))
727        };
728        for (flags, needle) in [
729            (0x241, "LZMA"),
730            (0x244, "LZHAM"),
731            (0x2c3, "end of the file"),
732            (0x203, "not stored with the directory"),
733            (0x1243, "header flags"),
734        ] {
735            let e = with_flags(flags).unwrap_err();
736            assert!(matches!(e, Error::Unsupported(_)), "{flags:#x}: {e}");
737            assert!(e.to_string().contains(needle), "{flags:#x}: {e}");
738        }
739        let mut v7 = good.clone();
740        v7[11] = 7;
741        let e = open(v7).unwrap_err();
742        assert!(
743            matches!(e, Error::Unsupported(_)) && e.to_string().contains("format version 7"),
744            "{e}"
745        );
746        let mut magic = good.clone();
747        magic[0] = b'X';
748        assert!(open(magic).unwrap_err().to_string().contains("signature"));
749        let mut cut = good.clone();
750        cut.pop();
751        let e = open(cut).unwrap_err().to_string();
752        assert!(e.contains("the header says"), "{e}");
753        let mut extra = good.clone();
754        extra.push(0);
755        assert!(open(extra).unwrap_err().to_string().contains("the header says"));
756        let a: Vec<u8> = (0..40u8).collect();
757        let outside = open(build(&[(&a, 3)], &[(30, 20, 4, "n")], 3, 0x243))
758            .unwrap_err()
759            .to_string();
760        assert!(outside.contains("outside the blocks"), "{outside}");
761        let twice = open(build(&[(&a, 3)], &[(0, 1, 4, "n"), (1, 1, 4, "n")], 3, 0x243))
762            .unwrap_err()
763            .to_string();
764        assert!(twice.contains("twice"), "{twice}");
765        let negative = open(build(&[(&a, 3)], &[(-1, 1, 4, "n")], 3, 0x243))
766            .unwrap_err()
767            .to_string();
768        assert!(negative.contains("offset -1"), "{negative}");
769    }
770
771    /// A block that claims more bytes than its LZ4 can make is refused before anything is allocated for it.
772    #[test]
773    fn a_block_cannot_claim_more_than_its_lz4_can_make() {
774        let a: Vec<u8> = (0..40u8).collect();
775        let mut bytes = build(&[(&a, 3)], &[(0, 10, 4, "n")], 0, 0x240);
776        // the block's uncompressed size is the u32 after the 16-byte hash and the block count in the stored table
777        let table_at = bytes.windows(16).position(|w| w == [7; 16]).unwrap();
778        let size_at = table_at + 16 + 4;
779        bytes[size_at..size_at + 4].copy_from_slice(&u32::MAX.to_be_bytes());
780        let bundle = open(bytes).unwrap();
781        let e = bundle
782            .read(bundle.node("n").unwrap(), 0, 1)
783            .unwrap_err()
784            .to_string();
785        assert!(e.contains("lz4") || e.contains("LZ4"), "{e}");
786    }
787}