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_internal(len)
269    }
270
271    pub fn read_string_u8(&mut self) -> std::io::Result<String> {
272        let len = self.read_u8()? as usize;
273        self.read_string_internal(len)
274    }
275
276    fn read_string_internal(&mut self, len: usize) -> std::io::Result<String> {
277        let bytes = self.read_bytes(len)?;
278        // Avoid allocation for UTF-8 validation by using from_utf8_lossy internally,
279        // but preserve error semantics for actual validation
280        String::from_utf8(bytes)
281            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
282    }
283
284    pub fn read_nonce(&mut self) -> std::io::Result<[u8; 12]> {
285        let mut nonce = [0u8; 12];
286        self.inner.read_exact(&mut nonce)?;
287        Ok(nonce)
288    }
289}
290
291// additional helpers available when the inner reader also implements `Seek`
292impl<R: std::io::Read + std::io::Seek> PackReader<R> {
293    /// Seek the underlying reader to `pos` and return the new position.
294    pub fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
295        self.inner.seek(pos)
296    }
297}
298
299/// Internal helper for binary packing.
300pub(crate) struct PackWriter<W: std::io::Write> {
301    inner: W,
302}
303
304impl<W: std::io::Write> PackWriter<W> {
305    pub fn new(inner: W) -> Self {
306        Self { inner }
307    }
308
309    /// Encrypt `plaintext` with the provided key/nonce and write the
310    /// resulting ciphertext to the underlying writer.
311    #[allow(dead_code)]
312    pub fn write_encrypted(
313        &mut self,
314        key: &crate::EncryptionKey,
315        nonce: &[u8],
316        plaintext: &[u8],
317    ) -> Result<(), DlcError> {
318        use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
319        let cipher = key.with_secret(|kb| {
320            Aes256Gcm::new_from_slice(kb).map_err(|e| DlcError::CryptoError(e.to_string()))
321        })?;
322        let ct = cipher
323            .encrypt(Nonce::from_slice(nonce), plaintext)
324            .map_err(|_| DlcError::EncryptionFailed("block encryption failed".into()))?;
325        self.write_bytes(&ct).map_err(|e| DlcError::Other(e.to_string()))
326    }
327
328    pub fn write_u8(&mut self, val: u8) -> std::io::Result<()> {
329        self.inner.write_all(&[val])
330    }
331
332    pub fn write_u16(&mut self, val: u16) -> std::io::Result<()> {
333        self.inner.write_all(&val.to_be_bytes())
334    }
335
336    pub fn write_u32(&mut self, val: u32) -> std::io::Result<()> {
337        self.inner.write_all(&val.to_be_bytes())
338    }
339
340    pub fn write_u64(&mut self, val: u64) -> std::io::Result<()> {
341        self.inner.write_all(&val.to_be_bytes())
342    }
343
344    pub fn write_bytes(&mut self, bytes: &[u8]) -> std::io::Result<()> {
345        self.inner.write_all(bytes)
346    }
347
348    pub fn write_string_u16(&mut self, s: &str) -> std::io::Result<()> {
349        let bytes = s.as_bytes();
350        self.write_u16(bytes.len() as u16)?;
351        self.write_bytes(bytes)
352    }
353
354    pub fn finish(mut self) -> std::io::Result<W> {
355        self.inner.flush()?;
356        Ok(self.inner)
357    }
358}
359
360/// Represents the header of any version of a `.dlcpack`.
361struct PackHeader {
362    version: u8,
363    product: String,
364    dlc_id: String,
365}
366
367impl PackHeader {
368    fn read<R: std::io::Read>(reader: &mut PackReader<R>) -> std::io::Result<Self> {
369        let magic = reader.read_bytes(4)?;
370        if magic != DLC_PACK_MAGIC {
371            return Err(std::io::Error::new(
372                std::io::ErrorKind::InvalidData,
373                "invalid dlcpack magic",
374            ));
375        }
376
377        let version = reader.read_u8()?;
378        let mut product = String::new();
379
380        if version == 3 || version == 4 {
381            product = reader.read_string_u16()?;
382        } else if version > 4 {
383            return Err(std::io::Error::new(
384                std::io::ErrorKind::InvalidData,
385                format!("unsupported pack version: {}", version),
386            ));
387        }
388
389        let dlc_id = reader.read_string_u16()?;
390
391        Ok(PackHeader {
392            version,
393            product,
394            dlc_id,
395        })
396    }
397
398    fn write<W: std::io::Write>(&self, writer: &mut PackWriter<W>) -> std::io::Result<()> {
399        writer.write_bytes(DLC_PACK_MAGIC)?;
400        writer.write_u8(self.version)?;
401
402        if self.version == 3 || self.version == 4 {
403            writer.write_string_u16(&self.product)?;
404        }
405
406        writer.write_string_u16(&self.dlc_id)
407    }
408}
409
410/// Decrypt with a 32-byte AES key using AES-GCM.  This is the same logic that
411/// used to live in `lib.rs`; moving it here allows crates that only depend on
412/// the pack format helpers to perform decryption without pulling in the
413/// higher-level API.
414
415use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::AeadInPlace};
416use secure_gate::ExposeSecret;
417
418use crate::{DlcError, DlcId, EncryptionKey, PackItem, Product};
419
420pub(crate) fn decrypt_with_key<R: std::io::Read>(
421    key: &crate::EncryptionKey,
422    mut reader: R,
423    nonce: &[u8],
424) -> Result<Vec<u8>, DlcError> {
425    // read ciphertext into a single buffer; decrypt it in-place to avoid
426    // allocating a second plaintext buffer.
427    let mut buf = Vec::new();
428    reader
429        .read_to_end(&mut buf)
430        .map_err(|e| DlcError::Other(e.to_string()))?;
431
432    key.with_secret(|key_bytes| {
433        if key_bytes.len() != 32 {
434            return Err(DlcError::InvalidEncryptKey(
435                "encrypt key must be 32 bytes (AES-256)".into(),
436            ));
437        }
438        if nonce.len() != 12 {
439            return Err(DlcError::InvalidNonce(
440                "nonce must be 12 bytes (AES-GCM)".into(),
441            ));
442        }
443        let cipher = Aes256Gcm::new_from_slice(key_bytes)
444            .map_err(|e| DlcError::CryptoError(e.to_string()))?;
445        let nonce = Nonce::from_slice(nonce);
446        // decrypt in-place; `buf` will be overwritten with plaintext
447        cipher
448            .decrypt_in_place(nonce, &[], &mut buf)
449            .map_err(|_|
450                DlcError::DecryptionFailed(
451                    "authentication failed (incorrect key or corrupted ciphertext)".to_string(),
452                )
453            )
454    })?;
455    Ok(buf)
456}
457
458/// Compression level for packing. Controls the trade-off between pack size and packing time.
459/// Higher levels produce smaller files but take longer to create.
460#[derive(Debug, Clone, Copy)]
461pub enum CompressionLevel {
462    /// Fast compression (level 1), suitable for rapid iterations
463    Fast,
464    /// Balanced compression (level 6, default), good trade-off
465    Default,
466    /// Best compression (level 9), smallest file size for distribution
467    Best,
468}
469
470impl From<CompressionLevel> for flate2::Compression {
471    fn from(level: CompressionLevel) -> Self {
472        match level {
473            CompressionLevel::Fast => flate2::Compression::fast(),
474            CompressionLevel::Default => flate2::Compression::default(),
475            CompressionLevel::Best => flate2::Compression::best(),
476        }
477    }
478}
479
480/// Pack multiple entries into a v4 hybrid format `.dlcpack` container.
481pub fn pack_encrypted_pack_v4(
482    dlc_id: &DlcId,
483    items: &[PackItem],
484    product: &Product,
485    key: &EncryptionKey,
486    block_size: usize,
487) -> Result<Vec<u8>, DlcError> {
488    use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
489    use flate2::Compression;
490    use flate2::write::GzEncoder;
491    use tar::Builder;
492
493    let cipher = key.with_secret(|kb| {
494        Aes256Gcm::new_from_slice(kb.as_slice()).map_err(|e| DlcError::CryptoError(e.to_string()))
495    })?;
496
497    // 1. Group items into blocks
498    let mut blocks: Vec<Vec<&PackItem>> = Vec::new();
499    let mut current_block = Vec::new();
500    let mut current_size = 0;
501
502    for item in items {
503        if !current_block.is_empty() && current_size + item.plaintext.len() > block_size {
504            blocks.push(std::mem::take(&mut current_block));
505            current_size = 0;
506        }
507        current_size += item.plaintext.len();
508        current_block.push(item);
509    }
510    if !current_block.is_empty() {
511        blocks.push(current_block);
512    }
513
514    // 2. Build blocks and track offsets
515    let mut encrypted_blocks = Vec::new();
516    let mut manifest_entries = Vec::new();
517    let mut block_metadatas = Vec::new();
518
519    for (block_id, block_items) in blocks.into_iter().enumerate() {
520        let block_id = block_id as u32;
521        let mut tar_gz = Vec::new();
522        let mut uncompressed_size = 0;
523        {
524            let mut gz = GzEncoder::new(&mut tar_gz, Compression::default());
525            {
526                let mut tar = Builder::new(&mut gz);
527                let mut offset = 0;
528
529                for item in block_items {
530                    let mut header = tar::Header::new_gnu();
531                    header.set_size(item.plaintext.len() as u64);
532                    header.set_mode(0o644);
533                    header.set_cksum();
534
535                    let path_str = item.path.clone();
536                    manifest_entries.push(V4ManifestEntry {
537                        path: path_str,
538                        original_extension: item.original_extension.clone().unwrap_or_default(),
539                        type_path: item.type_path.clone(),
540                        block_id,
541                        block_offset: offset,
542                        size: item.plaintext.len() as u32,
543                    });
544
545                    tar.append_data(&mut header, &item.path, &item.plaintext[..])
546                        .map_err(|e| DlcError::Other(e.to_string()))?;
547
548                    // tar header is 512, plus data (padded to 512)
549                    let data_len = item.plaintext.len() as u32;
550                    let padded_len = (data_len + 511) & !511;
551                    offset += 512 + padded_len;
552                    uncompressed_size += data_len;
553                }
554                tar.finish().map_err(|e| DlcError::Other(e.to_string()))?;
555            }
556            gz.finish().map_err(|e| DlcError::Other(e.to_string()))?;
557        }
558
559        // Encrypt block
560        let nonce_bytes: [u8; 12] = rand::random();
561        let nonce = Nonce::from_slice(&nonce_bytes);
562        let ciphertext = cipher
563            .encrypt(nonce, tar_gz.as_slice())
564            .map_err(|_| DlcError::EncryptionFailed("block encryption failed".into()))?;
565
566        let crc32 = crc32fast::hash(&ciphertext);
567
568        block_metadatas.push(BlockMetadata {
569            block_id,
570            file_offset: 0, // Fill later
571            encrypted_size: ciphertext.len() as u32,
572            uncompressed_size,
573            nonce: nonce_bytes,
574            crc32,
575        });
576
577        encrypted_blocks.push(ciphertext);
578    }
579
580    // 3. Assemble final binary
581    let product_str = product.as_ref();
582    let dlc_id_str = dlc_id.to_string();
583
584    let mut out = Vec::new();
585    {
586        let mut writer = PackWriter::new(&mut out);
587
588        let header = PackHeader {
589            version: 4,
590            product: product_str.to_string(),
591            dlc_id: dlc_id_str.clone(),
592        };
593        header.write(&mut writer).map_err(|e| DlcError::Other(e.to_string()))?;
594
595        // Manifest
596        writer.write_u32(manifest_entries.len() as u32).map_err(|e| DlcError::Other(e.to_string()))?;
597        for entry in &manifest_entries {
598            entry.write_binary(&mut writer).map_err(|e| DlcError::Other(e.to_string()))?;
599        }
600
601        // Block Metadata placeholder
602        writer.write_u32(block_metadatas.len() as u32).map_err(|e| DlcError::Other(e.to_string()))?;
603        writer.finish().map_err(|e| DlcError::Other(e.to_string()))?;
604    }
605    let metadata_start_pos = out.len();
606    {
607        let mut writer = PackWriter::new(&mut out);
608        for meta in &block_metadatas {
609            meta.write_binary(&mut writer).map_err(|e| DlcError::Other(e.to_string()))?;
610        }
611        writer.finish().map_err(|e| DlcError::Other(e.to_string()))?;
612    }
613
614    // Encrypted Blocks
615    for (i, block) in encrypted_blocks.into_iter().enumerate() {
616        let pos = out.len() as u64;
617        block_metadatas[i].file_offset = pos;
618        out.extend_from_slice(&block);
619    }
620
621    // Rewrite block metadatas with correct file_offsets
622    {
623        let mut writer_fixed = PackWriter::new(&mut out[metadata_start_pos..]);
624        for meta in &block_metadatas {
625            meta.write_binary(&mut writer_fixed).map_err(|e| DlcError::Other(e.to_string()))?;
626        }
627        writer_fixed.finish().map_err(|e| DlcError::Other(e.to_string()))?;
628    }
629
630    Ok(out)
631}
632
633/// Returns: (product, dlc_id, version, entries)
634pub fn parse_encrypted_pack<R: std::io::Read>(
635    reader: R,
636) -> Result<
637    (
638        Product,
639        DlcId,
640        usize,
641        Vec<(String, crate::asset_loader::EncryptedAsset)>,
642        Vec<BlockMetadata>,
643    ),
644    std::io::Error,
645> {
646    use std::io::ErrorKind;
647
648    let mut reader = PackReader::new(reader);
649    let header = PackHeader::read(&mut reader)?;
650    let mut block_metadatas = Vec::new();
651
652    let entries = if header.version == 1 {
653        // legacy v1 format: each entry has its own metadata and ciphertext
654        let entry_count = reader.read_u16()? as usize;
655        let mut out = Vec::with_capacity(entry_count);
656        for _ in 0..entry_count {
657            let path = reader.read_string_u16()?;
658            let original_extension = reader.read_string_u8()?;
659            
660            // v1: optional type_path
661            let tlen = reader.read_u16()? as usize;
662            let type_path = if tlen == 0 {
663                None
664            } else {
665                let bytes = reader.read_bytes(tlen)?;
666                let s = String::from_utf8(bytes)
667                    .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
668                Some(s)
669            };
670
671            let nonce = reader.read_nonce()?;
672            let ciphertext_len = reader.read_u32()? as usize;
673            let ciphertext = reader.read_bytes(ciphertext_len)?.into();
674
675            out.push((
676                path,
677                crate::asset_loader::EncryptedAsset {
678                    dlc_id: header.dlc_id.clone(),
679                    original_extension,
680                    type_path,
681                    nonce,
682                    ciphertext,
683                    block_id: 0,
684                    block_offset: 0,
685                    size: 0,
686                },
687            ));
688        }
689        out
690    } else if header.version == 4 {
691        // version 4: hybrid multi-block format
692        let manifest_count = reader.read_u32()? as usize;
693        let mut manifest: Vec<V4ManifestEntry> = Vec::with_capacity(manifest_count);
694        for _ in 0..manifest_count {
695            manifest.push(V4ManifestEntry::read_binary(&mut reader)?);
696        }
697
698        let block_count = reader.read_u32()? as usize;
699        block_metadatas = Vec::with_capacity(block_count);
700        for _ in 0..block_count {
701            block_metadatas.push(BlockMetadata::read_binary(&mut reader)?);
702        }
703
704        let mut out = Vec::with_capacity(manifest.len());
705        for entry in manifest {
706            out.push((
707                entry.path,
708                crate::asset_loader::EncryptedAsset {
709                    dlc_id: header.dlc_id.clone(),
710                    original_extension: entry.original_extension,
711                    type_path: entry.type_path,
712                    nonce: [0u8; 12],
713                    ciphertext: std::sync::Arc::new([]),
714                    block_id: entry.block_id,
715                    block_offset: entry.block_offset,
716                    size: entry.size,
717                },
718            ));
719        }
720        out
721    } else {
722        // version 2/3: manifest JSON followed by shared nonce and ciphertext
723        let manifest_len = reader.read_u32()? as usize;
724        let manifest_bytes = reader.read_bytes(manifest_len)?;
725        let manifest: Vec<ManifestEntry> = serde_json::from_slice(&manifest_bytes)
726            .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
727
728        let mut out = Vec::with_capacity(manifest.len());
729
730        let shared_nonce = reader.read_nonce()?;
731        let shared_ciphertext_len = reader.read_u32()? as usize;
732        let shared_ciphertext: std::sync::Arc<[u8]> = reader.read_bytes(shared_ciphertext_len)?.into();
733
734        for entry in manifest {
735            out.push((
736                entry.path,
737                crate::asset_loader::EncryptedAsset {
738                    dlc_id: header.dlc_id.clone(),
739                    original_extension: entry.original_extension.unwrap_or_default(),
740                    type_path: entry.type_path,
741                    nonce: shared_nonce,
742                    ciphertext: shared_ciphertext.clone(),
743                    block_id: 0,
744                    block_offset: 0,
745                    size: shared_ciphertext.len() as u32,
746                },
747            ));
748        }
749        out
750    };
751
752    Ok((
753        Product::from(header.product),
754        DlcId::from(header.dlc_id),
755        header.version as usize,
756        entries,
757        block_metadatas,
758    ))
759}
760
761/// Pack multiple entries into a single `.dlcpack` container.
762///
763/// Arguments:
764/// - `dlc_id`: the [DlcId] this pack belongs to (used for registry lookup and validation)
765/// - `items`: a list of [PackItem]s containing the plaintext data to be packed
766/// - `product`: the [Product] identifier to bind the pack to
767/// - `key`: the symmetric encryption key used to encrypt the pack contents (must be 32 bytes for AES-256)
768pub fn pack_encrypted_pack(
769    dlc_id: &DlcId,
770    items: &[PackItem],
771    product: &Product,
772    key: &EncryptionKey,
773) -> Result<Vec<u8>, DlcError> {
774    pack_encrypted_pack_v4(dlc_id, items, product, key, DEFAULT_BLOCK_SIZE)
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn forbidden_list_contains_known() {
783        assert!(is_forbidden_extension("exe"));
784        assert!(is_forbidden_extension("EXE"));
785        assert!(!is_forbidden_extension("png"));
786    }
787
788    #[test]
789    fn manifest_roundtrip() {
790        let item = crate::PackItem::new("foo.txt", b"hello" as &[u8]).unwrap();
791        let entry = ManifestEntry::from_pack_item(&item);
792        let bytes = serde_json::to_vec(&entry).unwrap();
793        let back: ManifestEntry = serde_json::from_slice(&bytes).unwrap();
794        assert_eq!(entry.path, back.path);
795    }
796}