Skip to main content

bevy_dlc/
pack_format.rs

1use std::{collections::HashSet, sync::LazyLock};
2
3/// .dlcpack container magic header (4 bytes) used to identify encrypted pack containers.
4pub const DLC_PACK_MAGIC: &[u8; 4] = b"BDLP";
5
6/// Current supported .dlcpack format version. This is stored in the container header and used to determine how to parse the contents.
7pub const DLC_PACK_VERSION_LATEST: u8 = 4;
8
9/// Default block size for v4 hybrid format (10 MB). Blocks are individually encrypted tar.gz chunks.
10pub const DEFAULT_BLOCK_SIZE: usize = 10 * 1024 * 1024;
11
12/// List of file extensions that are never allowed to be packed.  These are
13/// taken from the same array that used to live in [lib.rs]; we keep the
14/// array for deterministic build and the hash set for fast membership tests.
15pub const FORBIDDEN_EXTENSIONS: &[&str] = &[
16    "7z", "accda", "accdb", "accde", "accdr", "ace", "ade", "adp", "app", "appinstaller", "application", "appref", "appx", "appxbundle", "arj", "asax", "asd", "ashx", "asp", "aspx", "b64", "bas", "bat", "bgi", "bin", "btm", "bz", "bz2", "bzip", "bzip2", "cab", "cer", "cfg", "chi", "chm", "cla", "class", "cmd", "com", "cpi", "cpio", "cpl", "crt", "crx", "csh", "der", "desktopthemefile", "diagcab", "diagcfg", "diagpkg", "dll", "dmg", "doc", "docm", "docx", "dotm", "drv", "eml", "exe", "fon", "fxp", "gadget", "grp", "gz", "gzip", "hlp", "hta", "htc", "htm", "html", "htt", "ics", "img", "ini", "ins", "inx", "iqy", "iso", "isp", "isu", "jar", "jnlp", "job", "js", "jse", "ksh", "lha", "lnk", "local", "lz", "lzh", "lzma", "mad", "maf", "mag", "mam", "manifest", "maq", "mar", "mas", "mat", "mav", "maw", "mda", "mdb", "mde", "mdt", "mdw", "mdz", "mht", "mhtml", "mmc", "msc", "msg", "msh", "msh1", "msh1xml", "msh2", "msh2xml", "mshxml", "msi", "msix", "msixbundle", "msm", "msp", "mst", "msu", "ocx", "odt", "one", "onepkg", "onetoc", "onetoc2", "ops", "oxps", "oxt", "paf", "partial", "pcd", "pdf", "pif", "pl", "plg", "pol", "potm", "ppam", "ppkg", "ppsm", "ppt", "pptm", "pptx", "prf", "prg", "ps1", "ps1xml", "ps2", "ps2xml", "psc1", "psc2", "psm1", "pst", "r00", "r01", "r02", "r03", "rar", "reg", "rels", "rev", "rgs", "rpm", "rtf", "scf", "scr", "sct", "search", "settingcontent", "settingscontent", "sh", "shb", "sldm", "slk", "svg", "swf", "sys", "tar", "tbz", "tbz2", "tgz", "tlb", "url", "uue", "vb", "vbe", "vbs", "vbscript", "vdx", "vhd", "vhdx", "vsmacros", "vss", "vssm", "vssx", "vst", "vstm", "vstx", "vsw", "vsx", "vtx", "wbk", "webarchive", "website", "wml", "ws", "wsc", "wsf", "wsh", "xar", "xbap", "xdp", "xlam", "xll", "xlm", "xls", "xlsb", "xlsm", "xlsx", "xltm", "xlw", "xml", "xnk", "xps", "xrm", "xsd", "xsl", "xxe", "xz", "z", "zip",
17];
18
19/// Lazy hash set used by [`is_forbidden_extension`].
20static FORBIDDEN_SET: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
21    FORBIDDEN_EXTENSIONS.iter().copied().collect()
22});
23
24/// Return true if the extension (case‑insensitive) is in the forbidden list.
25pub fn is_forbidden_extension(ext: &str) -> bool {
26    let lowercase = ext.trim_start_matches('.').to_ascii_lowercase();
27    FORBIDDEN_SET.contains(lowercase.as_str())
28}
29
30// `is_malicious_file` used to live here but its functionality is now
31// subsumed by `is_data_executable` which is called during packing.  Keeping
32// the helper around only bloated the public API and required test plumbing
33// that we no longer need, so the function has been removed.
34
35// NOTE: if additional heuristics are required in the future they can be
36// reintroduced or re‑exported from the CLI/generation layer rather than the
37// core format library.
38/// Simple heuristic used by the packer to detect executable payloads.  It is
39/// intentionally forgiving; the goal is merely to catch obvious binaries when
40/// a user accidentally tries to pack them.
41pub fn is_data_executable(data: &[u8]) -> bool {
42    if infer::is_app(data) {
43        return true;
44    }
45    if data.starts_with(b"#!") {
46        return true;
47    }
48    false
49}
50
51/// Representation of a single manifest entry inside a v2+ `.dlcpack`.
52#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
53pub struct ManifestEntry {
54    pub path: String,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub original_extension: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub type_path: Option<String>,
59}
60
61impl ManifestEntry {
62    pub fn from_pack_item(item: &crate::PackItem) -> Self {
63        ManifestEntry {
64            path: item.path.clone(),
65            original_extension: item
66                .original_extension
67                .clone()
68                .filter(|s| !s.is_empty()),
69            type_path: item.type_path.clone(),
70        }
71    }
72}
73
74/// V4 manifest entry: binary-serializable format for faster parsing.
75/// Format: path_len(u32) + path(utf8) + ext_len(u8) + ext(utf8) + type_len(u16) + type(utf8) + block_id(u32) + block_offset(u32) + size(u32)
76#[derive(Clone, Debug)]
77pub struct V4ManifestEntry {
78    pub path: String,
79    pub original_extension: String,
80    pub type_path: Option<String>,
81    /// Which block contains this asset
82    pub block_id: u32,
83    /// Offset within the decompressed block's tar archive
84    pub block_offset: u32,
85    /// Uncompressed size (for progress/buffer allocation)
86    pub size: u32,
87}
88
89impl V4ManifestEntry {
90    pub fn from_pack_item(item: &crate::PackItem, block_id: u32, block_offset: u32) -> Self {
91        V4ManifestEntry {
92            path: item.path.clone(),
93            original_extension: item.original_extension.clone().unwrap_or_default(),
94            type_path: item.type_path.clone(),
95            block_id,
96            block_offset,
97            size: item.plaintext.len() as u32,
98        }
99    }
100
101    /// Write this entry in binary format (used by v4 format conversion)
102    #[allow(dead_code)]
103    fn write_binary<W: std::io::Write>(&self, writer: &mut PackWriter<W>) -> std::io::Result<()> {
104        writer.write_u32(self.path.len() as u32)?;
105        writer.write_bytes(self.path.as_bytes())?;
106
107        writer.write_u8(self.original_extension.len() as u8)?;
108        writer.write_bytes(self.original_extension.as_bytes())?;
109
110        if let Some(ref tp) = self.type_path {
111            writer.write_u16(tp.len() as u16)?;
112            writer.write_bytes(tp.as_bytes())?;
113        } else {
114            writer.write_u16(0)?;
115        }
116
117        writer.write_u32(self.block_id)?;
118        writer.write_u32(self.block_offset)?;
119        writer.write_u32(self.size)
120    }
121
122    /// Read binary format from reader (used by v4 format parsing)
123    #[allow(dead_code)]
124    fn read_binary<R: std::io::Read>(reader: &mut PackReader<R>) -> std::io::Result<Self> {
125        let path_len = reader.read_u32()? as usize;
126        let path = String::from_utf8(reader.read_bytes(path_len)?)
127            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
128
129        let ext_len = reader.read_u8()? as usize;
130        let original_extension = if ext_len > 0 {
131            String::from_utf8(reader.read_bytes(ext_len)?)
132                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?
133        } else {
134            String::new()
135        };
136
137        let type_len = reader.read_u16()? as usize;
138        let type_path = if type_len > 0 {
139            let tp = String::from_utf8(reader.read_bytes(type_len)?)
140                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
141            Some(tp)
142        } else {
143            None
144        };
145
146        let block_id = reader.read_u32()?;
147        let block_offset = reader.read_u32()?;
148        let size = reader.read_u32()?;
149
150        Ok(V4ManifestEntry {
151            path,
152            original_extension,
153            type_path,
154            block_id,
155            block_offset,
156            size,
157        })
158    }
159}
160
161/// V4 block metadata: stores information about a tar-gz block within the pack file
162#[derive(Clone, Debug)]
163pub struct BlockMetadata {
164    pub block_id: u32,
165    pub file_offset: u64,      // Where this block starts in the file
166    pub encrypted_size: u32,   // Size of encrypted ciphertext
167    pub uncompressed_size: u32, // Size after decompression (for buffer allocation)
168    pub nonce: [u8; 12],       // Per-block nonce
169    pub crc32: u32,            // CRC32 checksum for integrity
170}
171
172impl BlockMetadata {
173    #[allow(dead_code)]
174    fn write_binary<W: std::io::Write>(&self, writer: &mut PackWriter<W>) -> std::io::Result<()> {
175        writer.write_u32(self.block_id)?;
176        writer.write_u64(self.file_offset)?;
177        writer.write_u32(self.encrypted_size)?;
178        writer.write_u32(self.uncompressed_size)?;
179        writer.write_bytes(&self.nonce)?;
180        writer.write_u32(self.crc32)
181    }
182
183    #[allow(dead_code)]
184    fn read_binary<R: std::io::Read>(reader: &mut PackReader<R>) -> std::io::Result<Self> {
185        let block_id = reader.read_u32()?;
186        let file_offset = reader.read_u64()?;
187        let encrypted_size = reader.read_u32()?;
188        let uncompressed_size = reader.read_u32()?;
189        let nonce = reader.read_nonce()?;
190        let crc32 = reader.read_u32()?;
191
192        Ok(BlockMetadata {
193            block_id,
194            file_offset,
195            encrypted_size,
196            uncompressed_size,
197            nonce,
198            crc32,
199        })
200    }
201}
202
203// converters and migration helpers have been removed – the crate
204// now only supports v4 packs.  The old `PackConverter` trait, the
205// `V3toV4Converter` implementation and the `PackConverterRegistry` type
206// were used to upgrade on‑disk packs to the latest format; they are
207// retained in history if needed but no longer compiled.
208
209/// Internal helper for binary parsing with offset management.
210pub(crate) struct PackReader<R: std::io::Read> {
211    inner: R,
212}
213
214impl<R: std::io::Read> PackReader<R> {
215    pub fn new(inner: R) -> Self {
216        Self { inner }
217    }
218
219    /// Read a byte.
220    pub fn read_u8(&mut self) -> std::io::Result<u8> {
221        let mut buf = [0u8; 1];
222        self.inner.read_exact(&mut buf)?;
223        Ok(buf[0])
224    }
225
226    /// Convenience method: read `len` bytes and immediately decrypt them with
227    /// the provided AES-GCM key/nonce.
228    #[allow(dead_code)]
229    pub fn read_and_decrypt(
230        &mut self,
231        key: &crate::EncryptionKey,
232        len: usize,
233        nonce: &[u8],
234    ) -> Result<Vec<u8>, DlcError> {
235        let ciphertext = self.read_bytes(len)?;
236
237        let cursor = std::io::Cursor::new(ciphertext);
238        crate::pack_format::decrypt_with_key(key, cursor, nonce)
239    }
240
241    pub fn read_u16(&mut self) -> std::io::Result<u16> {
242        let mut buf = [0u8; 2];
243        self.inner.read_exact(&mut buf)?;
244        Ok(u16::from_be_bytes(buf))
245    }
246
247    pub fn read_u32(&mut self) -> std::io::Result<u32> {
248        let mut buf = [0u8; 4];
249        self.inner.read_exact(&mut buf)?;
250        Ok(u32::from_be_bytes(buf))
251    }
252
253    #[allow(dead_code)]
254    pub fn read_u64(&mut self) -> std::io::Result<u64> {
255        let mut buf = [0u8; 8];
256        self.inner.read_exact(&mut buf)?;
257        Ok(u64::from_be_bytes(buf))
258    }
259
260    pub fn read_bytes(&mut self, len: usize) -> std::io::Result<Vec<u8>> {
261        let mut buf = vec![0u8; len];
262        self.inner.read_exact(&mut buf)?;
263        Ok(buf)
264    }
265
266    pub fn read_string_u16(&mut self) -> std::io::Result<String> {
267        let len = self.read_u16()? as usize;
268        self.read_string(len)
269    }
270
271    pub fn read_string(&mut self, len: usize) -> std::io::Result<String> {
272        let bytes = self.read_bytes(len)?;
273        // Avoid allocation for UTF-8 validation by using from_utf8_lossy internally,
274        // but preserve error semantics for actual validation
275        String::from_utf8(bytes)
276            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
277    }
278
279    pub fn read_nonce(&mut self) -> std::io::Result<[u8; 12]> {
280        let mut nonce = [0u8; 12];
281        self.inner.read_exact(&mut nonce)?;
282        Ok(nonce)
283    }
284}
285
286// additional helpers available when the inner reader also implements `Seek`
287#[allow(unused)]
288impl<R: std::io::Read + std::io::Seek> PackReader<R> {
289    /// Seek the underlying reader to `pos` and return the new position.
290    pub fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
291        self.inner.seek(pos)
292    }
293}
294
295/// Internal helper for binary packing.
296pub(crate) struct PackWriter<W: std::io::Write> {
297    inner: W,
298}
299
300impl<W: std::io::Write> PackWriter<W> {
301    pub fn new(inner: W) -> Self {
302        Self { inner }
303    }
304
305    /// Encrypt `plaintext` with the provided key/nonce and write the
306    /// resulting ciphertext to the underlying writer.
307    #[allow(dead_code)]
308    pub fn write_encrypted(
309        &mut self,
310        key: &crate::EncryptionKey,
311        nonce: &[u8],
312        plaintext: &[u8],
313    ) -> Result<(), DlcError> {
314        use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
315        let cipher = key.with_secret(|kb| {
316            Aes256Gcm::new_from_slice(kb).map_err(|e| DlcError::CryptoError(e.to_string()))
317        })?;
318        let ct = cipher
319            .encrypt(Nonce::from_slice(nonce), plaintext)
320            .map_err(|_| DlcError::EncryptionFailed("block encryption failed".into()))?;
321        self.write_bytes(&ct).map_err(|e| DlcError::Other(e.to_string()))
322    }
323
324    pub fn write_u8(&mut self, val: u8) -> std::io::Result<()> {
325        self.inner.write_all(&[val])
326    }
327
328    pub fn write_u16(&mut self, val: u16) -> std::io::Result<()> {
329        self.inner.write_all(&val.to_be_bytes())
330    }
331
332    pub fn write_u32(&mut self, val: u32) -> std::io::Result<()> {
333        self.inner.write_all(&val.to_be_bytes())
334    }
335
336    pub fn write_u64(&mut self, val: u64) -> std::io::Result<()> {
337        self.inner.write_all(&val.to_be_bytes())
338    }
339
340    pub fn write_bytes(&mut self, bytes: &[u8]) -> std::io::Result<()> {
341        self.inner.write_all(bytes)
342    }
343
344    pub fn write_string_u16(&mut self, s: &str) -> std::io::Result<()> {
345        let bytes = s.as_bytes();
346        self.write_u16(bytes.len() as u16)?;
347        self.write_bytes(bytes)
348    }
349
350    pub fn finish(mut self) -> std::io::Result<W> {
351        self.inner.flush()?;
352        Ok(self.inner)
353    }
354}
355
356/// Represents the header of any version of a `.dlcpack`.
357struct PackHeader {
358    version: u8,
359    product: String,
360    dlc_id: String,
361}
362
363impl PackHeader {
364    fn read<R: std::io::Read>(reader: &mut PackReader<R>) -> std::io::Result<Self> {
365        let magic = reader.read_bytes(4)?;
366        if magic != DLC_PACK_MAGIC {
367            return Err(std::io::Error::new(
368                std::io::ErrorKind::InvalidData,
369                "invalid dlcpack magic",
370            ));
371        }
372
373        let version = reader.read_u8()?;
374        let mut product = String::new();
375
376        if version == 3 || version == 4 {
377            product = reader.read_string_u16()?;
378        } else if version > 4 {
379            return Err(std::io::Error::new(
380                std::io::ErrorKind::InvalidData,
381                format!("unsupported pack version: {}", version),
382            ));
383        }
384
385        let dlc_id = reader.read_string_u16()?;
386
387        Ok(PackHeader {
388            version,
389            product,
390            dlc_id,
391        })
392    }
393
394    fn write<W: std::io::Write>(&self, writer: &mut PackWriter<W>) -> std::io::Result<()> {
395        writer.write_bytes(DLC_PACK_MAGIC)?;
396        writer.write_u8(self.version)?;
397
398        if self.version == 3 || self.version == 4 {
399            writer.write_string_u16(&self.product)?;
400        }
401
402        writer.write_string_u16(&self.dlc_id)
403    }
404}
405
406/// Decrypt with a 32-byte AES key using AES-GCM.  This is the same logic that
407/// used to live in `lib.rs`; moving it here allows crates that only depend on
408/// the pack format helpers to perform decryption without pulling in the
409/// higher-level API.
410
411use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::AeadInPlace};
412use secure_gate::ExposeSecret;
413
414use crate::{DlcError, DlcId, EncryptionKey, PackItem, Product};
415
416pub(crate) fn decrypt_with_key<R: std::io::Read>(
417    key: &crate::EncryptionKey,
418    mut reader: R,
419    nonce: &[u8],
420) -> Result<Vec<u8>, DlcError> {
421    // read ciphertext into a single buffer; decrypt it in-place to avoid
422    // allocating a second plaintext buffer.
423    let mut buf = Vec::new();
424    reader
425        .read_to_end(&mut buf)
426        .map_err(|e| DlcError::Other(e.to_string()))?;
427
428    key.with_secret(|key_bytes| {
429        if key_bytes.len() != 32 {
430            return Err(DlcError::InvalidEncryptKey(
431                "encrypt key must be 32 bytes (AES-256)".into(),
432            ));
433        }
434        if nonce.len() != 12 {
435            return Err(DlcError::InvalidNonce(
436                "nonce must be 12 bytes (AES-GCM)".into(),
437            ));
438        }
439        let cipher = Aes256Gcm::new_from_slice(key_bytes)
440            .map_err(|e| DlcError::CryptoError(e.to_string()))?;
441        let nonce = Nonce::from_slice(nonce);
442        // decrypt in-place; `buf` will be overwritten with plaintext
443        cipher
444            .decrypt_in_place(nonce, &[], &mut buf)
445            .map_err(|_|
446                DlcError::DecryptionFailed(
447                    "authentication failed (incorrect key or corrupted ciphertext)".to_string(),
448                )
449            )
450    })?;
451    Ok(buf)
452}
453
454/// Compression level for packing. Controls the trade-off between pack size and packing time.
455/// Higher levels produce smaller files but take longer to create.
456#[derive(Debug, Clone, Copy)]
457pub enum CompressionLevel {
458    /// Fast compression (level 1), suitable for rapid iterations
459    Fast,
460    /// Balanced compression (level 6, default), good trade-off
461    Default,
462    /// Best compression (level 9), smallest file size for distribution
463    Best,
464}
465
466impl From<CompressionLevel> for flate2::Compression {
467    fn from(level: CompressionLevel) -> Self {
468        match level {
469            CompressionLevel::Fast => flate2::Compression::fast(),
470            CompressionLevel::Default => flate2::Compression::default(),
471            CompressionLevel::Best => flate2::Compression::best(),
472        }
473    }
474}
475
476/// Pack multiple entries into a single encrypted `.dlcpack` binary using the latest version format.  This is the main entry point for pack creation and is used by the CLI tool; it can also be used by other tools that want to generate packs programmatically.
477pub fn pack_encrypted_pack(
478    dlc_id: &DlcId,
479    items: &[PackItem],
480    product: &Product,
481    key: &EncryptionKey,
482    block_size: usize,
483) -> Result<Vec<u8>, DlcError> {
484    use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
485    use flate2::Compression;
486    use flate2::write::GzEncoder;
487    use tar::Builder;
488
489    let cipher = key.with_secret(|kb| {
490        Aes256Gcm::new_from_slice(kb.as_slice()).map_err(|e| DlcError::CryptoError(e.to_string()))
491    })?;
492
493    // 1. Group items into blocks
494    let mut blocks: Vec<Vec<&PackItem>> = Vec::new();
495    let mut current_block = Vec::new();
496    let mut current_size = 0;
497
498    for item in items {
499        if !current_block.is_empty() && current_size + item.plaintext.len() > block_size {
500            blocks.push(std::mem::take(&mut current_block));
501            current_size = 0;
502        }
503        current_size += item.plaintext.len();
504        current_block.push(item);
505    }
506    if !current_block.is_empty() {
507        blocks.push(current_block);
508    }
509
510    // 2. Build blocks and track offsets
511    let mut encrypted_blocks = Vec::new();
512    let mut manifest_entries = Vec::new();
513    let mut block_metadatas = Vec::new();
514
515    for (block_id, block_items) in blocks.into_iter().enumerate() {
516        let block_id = block_id as u32;
517        let mut tar_gz = Vec::new();
518        let mut uncompressed_size = 0;
519        {
520            let mut gz = GzEncoder::new(&mut tar_gz, Compression::default());
521            {
522                let mut tar = Builder::new(&mut gz);
523                let mut offset = 0;
524
525                for item in block_items {
526                    let mut header = tar::Header::new_gnu();
527                    header.set_size(item.plaintext.len() as u64);
528                    header.set_mode(0o644);
529                    header.set_cksum();
530
531                    let path_str = item.path.clone();
532                    manifest_entries.push(V4ManifestEntry {
533                        path: path_str,
534                        original_extension: item.original_extension.clone().unwrap_or_default(),
535                        type_path: item.type_path.clone(),
536                        block_id,
537                        block_offset: offset,
538                        size: item.plaintext.len() as u32,
539                    });
540
541                    tar.append_data(&mut header, &item.path, &item.plaintext[..])
542                        .map_err(|e| DlcError::Other(e.to_string()))?;
543
544                    // tar header is 512, plus data (padded to 512)
545                    let data_len = item.plaintext.len() as u32;
546                    let padded_len = (data_len + 511) & !511;
547                    offset += 512 + padded_len;
548                    uncompressed_size += data_len;
549                }
550                tar.finish().map_err(|e| DlcError::Other(e.to_string()))?;
551            }
552            gz.finish().map_err(|e| DlcError::Other(e.to_string()))?;
553        }
554
555        // Encrypt block
556        let nonce_bytes: [u8; 12] = rand::random();
557        let nonce = Nonce::from_slice(&nonce_bytes);
558        let ciphertext = cipher
559            .encrypt(nonce, tar_gz.as_slice())
560            .map_err(|_| DlcError::EncryptionFailed("block encryption failed".into()))?;
561
562        let crc32 = crc32fast::hash(&ciphertext);
563
564        block_metadatas.push(BlockMetadata {
565            block_id,
566            file_offset: 0, // Fill later
567            encrypted_size: ciphertext.len() as u32,
568            uncompressed_size,
569            nonce: nonce_bytes,
570            crc32,
571        });
572
573        encrypted_blocks.push(ciphertext);
574    }
575
576    // 3. Assemble final binary
577    let product_str = product.as_ref();
578    let dlc_id_str = dlc_id.to_string();
579
580    let mut out = Vec::new();
581    {
582        let mut writer = PackWriter::new(&mut out);
583
584        let header = PackHeader {
585            version: 4,
586            product: product_str.to_string(),
587            dlc_id: dlc_id_str.clone(),
588        };
589        header.write(&mut writer).map_err(|e| DlcError::Other(e.to_string()))?;
590
591        // Manifest
592        writer.write_u32(manifest_entries.len() as u32).map_err(|e| DlcError::Other(e.to_string()))?;
593        for entry in &manifest_entries {
594            entry.write_binary(&mut writer).map_err(|e| DlcError::Other(e.to_string()))?;
595        }
596
597        // Block Metadata placeholder
598        writer.write_u32(block_metadatas.len() as u32).map_err(|e| DlcError::Other(e.to_string()))?;
599        writer.finish().map_err(|e| DlcError::Other(e.to_string()))?;
600    }
601    let metadata_start_pos = out.len();
602    {
603        let mut writer = PackWriter::new(&mut out);
604        for meta in &block_metadatas {
605            meta.write_binary(&mut writer).map_err(|e| DlcError::Other(e.to_string()))?;
606        }
607        writer.finish().map_err(|e| DlcError::Other(e.to_string()))?;
608    }
609
610    // Encrypted Blocks
611    for (i, block) in encrypted_blocks.into_iter().enumerate() {
612        let pos = out.len() as u64;
613        block_metadatas[i].file_offset = pos;
614        out.extend_from_slice(&block);
615    }
616
617    // Rewrite block metadatas with correct file_offsets
618    {
619        let mut writer_fixed = PackWriter::new(&mut out[metadata_start_pos..]);
620        for meta in &block_metadatas {
621            meta.write_binary(&mut writer_fixed).map_err(|e| DlcError::Other(e.to_string()))?;
622        }
623        writer_fixed.finish().map_err(|e| DlcError::Other(e.to_string()))?;
624    }
625
626    Ok(out)
627}
628
629pub type Version = usize;
630
631/// Returns: (product, dlc_id, version, entries)
632pub fn parse_encrypted_pack<R: std::io::Read>(
633    reader: R,
634) -> Result<
635    (
636        Product,
637        DlcId,
638        Version,
639        Vec<(String, crate::asset_loader::EncryptedAsset)>,
640        Vec<BlockMetadata>,
641    ),
642    std::io::Error,
643> {
644    use std::io::ErrorKind;
645
646    let mut reader = PackReader::new(reader);
647    let header = PackHeader::read(&mut reader)?;
648    let mut block_metadatas = Vec::new();
649
650    let entries = if header.version < DLC_PACK_VERSION_LATEST {
651        return Err(std::io::Error::new(
652            ErrorKind::InvalidData,
653            format!("unsupported pack version: {}", header.version),
654        ));
655    } else if header.version == 4 {
656        // version 4: hybrid multi-block format
657        let manifest_count = reader.read_u32()? as usize;
658        let mut manifest: Vec<V4ManifestEntry> = Vec::with_capacity(manifest_count);
659        for _ in 0..manifest_count {
660            manifest.push(V4ManifestEntry::read_binary(&mut reader)?);
661        }
662
663        let block_count = reader.read_u32()? as usize;
664        block_metadatas = Vec::with_capacity(block_count);
665        for _ in 0..block_count {
666            block_metadatas.push(BlockMetadata::read_binary(&mut reader)?);
667        }
668
669        let mut out = Vec::with_capacity(manifest.len());
670        for entry in manifest {
671            out.push((
672                entry.path,
673                crate::asset_loader::EncryptedAsset {
674                    dlc_id: header.dlc_id.clone(),
675                    original_extension: entry.original_extension,
676                    type_path: entry.type_path,
677                    nonce: [0u8; 12],
678                    ciphertext: std::sync::Arc::new([]),
679                    block_id: entry.block_id,
680                    block_offset: entry.block_offset,
681                    size: entry.size,
682                },
683            ));
684        }
685        out
686    } else {
687        // version 2/3: manifest JSON followed by shared nonce and ciphertext
688        let manifest_len = reader.read_u32()? as usize;
689        let manifest_bytes = reader.read_bytes(manifest_len)?;
690        let manifest: Vec<ManifestEntry> = serde_json::from_slice(&manifest_bytes)
691            .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
692
693        let mut out = Vec::with_capacity(manifest.len());
694
695        let shared_nonce = reader.read_nonce()?;
696        let shared_ciphertext_len = reader.read_u32()? as usize;
697        let shared_ciphertext: std::sync::Arc<[u8]> = reader.read_bytes(shared_ciphertext_len)?.into();
698
699        for entry in manifest {
700            out.push((
701                entry.path,
702                crate::asset_loader::EncryptedAsset {
703                    dlc_id: header.dlc_id.clone(),
704                    original_extension: entry.original_extension.unwrap_or_default(),
705                    type_path: entry.type_path,
706                    nonce: shared_nonce,
707                    ciphertext: shared_ciphertext.clone(),
708                    block_id: 0,
709                    block_offset: 0,
710                    size: shared_ciphertext.len() as u32,
711                },
712            ));
713        }
714        out
715    };
716
717    Ok((
718        Product::from(header.product),
719        DlcId::from(header.dlc_id),
720        header.version as usize,
721        entries,
722        block_metadatas,
723    ))
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn forbidden_list_contains_known() {
732        assert!(is_forbidden_extension("exe"));
733        assert!(is_forbidden_extension("EXE"));
734        assert!(!is_forbidden_extension("png"));
735    }
736
737    #[test]
738    fn manifest_roundtrip() {
739        let item = crate::PackItem::new("foo.txt", b"hello" as &[u8]).unwrap();
740        let entry = ManifestEntry::from_pack_item(&item);
741        let bytes = serde_json::to_vec(&entry).unwrap();
742        let back: ManifestEntry = serde_json::from_slice(&bytes).unwrap();
743        assert_eq!(entry.path, back.path);
744    }
745}