Skip to main content

casc_lib/extract/
pipeline.rs

1//! Core CASC extraction pipeline.
2//!
3//! Provides [`CascStorage`] - a facade that bootstraps all CASC components
4//! (build info, build config, index, data store, encoding, root, listfile,
5//! and key store) from a WoW install directory - and the extraction functions
6//! [`extract_all`], [`extract_single_file`], and [`list_files`].
7
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use rayon::ThreadPoolBuilder;
12use rayon::prelude::*;
13
14use crate::blte::decoder::decode_blte_with_keys;
15use crate::blte::encryption::TactKeyStore;
16use crate::config::build_config::{BuildConfig, config_path, parse_build_config};
17use crate::config::build_info::{BuildInfo, list_products, parse_build_info};
18use crate::encoding::parser::EncodingFile;
19use crate::error::{CascError, Result};
20use crate::listfile::downloader::load_or_download;
21use crate::listfile::parser::Listfile;
22use crate::root::flags::LocaleFlags;
23use crate::root::parser::{RootEntry, RootFile, RootFormat};
24use crate::storage::data::DataStore;
25use crate::storage::index::CascIndex;
26
27use super::metadata::{ExtractionStats, MetadataEntry, MetadataWriter};
28
29// ---------------------------------------------------------------------------
30// Configuration types
31// ---------------------------------------------------------------------------
32
33/// Configuration for opening CASC storage.
34pub struct OpenConfig {
35    /// Path to the WoW install directory (the folder containing `.build.info`).
36    pub install_dir: PathBuf,
37    /// Filter by product name (e.g. "wow", "wow_classic").
38    pub product: Option<String>,
39    /// Optional custom keyfile path.
40    pub keyfile: Option<PathBuf>,
41    /// Optional custom listfile path.
42    pub listfile: Option<PathBuf>,
43    /// Directory for listfile cache and other output.
44    pub output_dir: Option<PathBuf>,
45}
46
47/// Extraction configuration.
48pub struct ExtractionConfig {
49    /// Directory where extracted files are written.
50    pub output_dir: PathBuf,
51    /// Raw locale bitmask used to filter root entries (e.g. `0x2` for enUS).
52    pub locale: u32,
53    /// Number of rayon worker threads for parallel extraction.
54    pub threads: usize,
55    /// When `true`, verify extracted file checksums against their CKey.
56    pub verify: bool,
57    /// When `true`, skip files marked with the `ENCRYPTED` content flag.
58    pub skip_encrypted: bool,
59    /// Optional glob pattern to filter files by listfile path.
60    pub filter: Option<String>,
61    /// When `true`, disable writing metadata files (JSONL, CSV, summary).
62    pub no_metadata: bool,
63}
64
65/// High-level storage info.
66pub struct StorageInfo {
67    /// Build name string from the build config (e.g. `"WOW-12345patch1.2.3"`).
68    pub build_name: String,
69    /// Product identifier from `.build.info` (e.g. `"wow"`, `"wow_classic"`).
70    pub product: String,
71    /// Client version string (e.g. `"12.0.1.66192"`).
72    pub version: String,
73    /// Number of entries in the encoding table.
74    pub encoding_entries: usize,
75    /// Total number of root file entries across all blocks.
76    pub root_entries: usize,
77    /// Detected root file format as a display string (`"Legacy"`, `"MfstV1"`, `"MfstV2"`).
78    pub root_format: String,
79    /// Number of entries in the CASC index.
80    pub index_entries: usize,
81    /// Number of entries in the loaded listfile.
82    pub listfile_entries: usize,
83}
84
85// ---------------------------------------------------------------------------
86// Central CASC storage facade
87// ---------------------------------------------------------------------------
88
89/// Central CASC storage facade that bootstraps all components and provides
90/// file read methods.
91pub struct CascStorage {
92    pub build_info: BuildInfo,
93    pub build_config: BuildConfig,
94    pub index: CascIndex,
95    pub data: DataStore,
96    pub encoding: EncodingFile,
97    pub root: RootFile,
98    pub listfile: Listfile,
99    pub keystore: TactKeyStore,
100}
101
102impl CascStorage {
103    /// Bootstrap all CASC components from an install directory.
104    ///
105    /// See [`OpenConfig`] for configuration options.
106    pub fn open(config: &OpenConfig) -> Result<Self> {
107        // 1. Read .build.info from install_dir (NOT Data/)
108        let build_info_path = config.install_dir.join(".build.info");
109        let build_info_content = std::fs::read_to_string(&build_info_path)?;
110        let all_entries = parse_build_info(&build_info_content)?;
111
112        // 2. Filter by product or take first active entry
113        let build_info = select_build_info(&all_entries, config.product.as_deref())?;
114
115        // 3. Read build config
116        let data_dir = config.install_dir.join("Data");
117        let config_rel = config_path(&build_info.build_key);
118        let config_file = data_dir.join(&config_rel);
119        let config_content = std::fs::read_to_string(&config_file)?;
120        let build_config = parse_build_config(&config_content)?;
121
122        // 4. Load CascIndex from Data/data/
123        let data_data_dir = data_dir.join("data");
124        let index = CascIndex::load(&data_data_dir)?;
125
126        // 5. Open DataStore from Data/data/
127        let data_store = DataStore::open(&data_data_dir)?;
128
129        // 6. Set up keystore
130        let mut keystore = TactKeyStore::with_known_keys();
131        if let Some(ref keyfile_path) = config.keyfile {
132            let custom = TactKeyStore::load_keyfile(keyfile_path)?;
133            keystore.merge(&custom);
134        }
135
136        // 7. Bootstrap encoding file
137        let encoding = bootstrap_encoding(&build_config, &index, &data_store, &keystore)?;
138
139        // 8. Bootstrap root file
140        let root = bootstrap_root(&build_config, &encoding, &index, &data_store, &keystore)?;
141
142        // 9. Load listfile
143        let listfile = load_listfile(config)?;
144
145        Ok(Self {
146            build_info,
147            build_config,
148            index,
149            data: data_store,
150            encoding,
151            root,
152            listfile,
153            keystore,
154        })
155    }
156
157    /// Read a file by its content key (CKey).
158    pub fn read_by_ckey(&self, ckey: &[u8; 16]) -> Result<Vec<u8>> {
159        let enc_entry = self
160            .encoding
161            .find_ekey(ckey)
162            .ok_or_else(|| CascError::KeyNotFound {
163                key_type: "CKey".into(),
164                hash: hex::encode(ckey),
165            })?;
166
167        let ekey = &enc_entry.ekeys[0];
168        let idx_entry = self
169            .index
170            .find(ekey)
171            .ok_or_else(|| CascError::KeyNotFound {
172                key_type: "EKey".into(),
173                hash: hex::encode(ekey),
174            })?;
175
176        let blte_data = self.data.read_raw(
177            idx_entry.archive_number,
178            idx_entry.archive_offset,
179            idx_entry.size,
180        )?;
181
182        decode_blte_with_keys(blte_data, Some(&self.keystore))
183    }
184
185    /// Read a file by FileDataID and locale.
186    pub fn read_by_fdid(&self, fdid: u32, locale: LocaleFlags) -> Result<Vec<u8>> {
187        let root_entry =
188            self.root
189                .find_by_fdid(fdid, locale)
190                .ok_or_else(|| CascError::KeyNotFound {
191                    key_type: "FDID".into(),
192                    hash: format!("{} (locale {})", fdid, locale),
193                })?;
194
195        self.read_by_ckey(&root_entry.ckey)
196    }
197
198    /// Return high-level statistics about the loaded storage.
199    pub fn info(&self) -> StorageInfo {
200        let root_format = match self.root.format() {
201            RootFormat::Legacy => "Legacy",
202            RootFormat::MfstV1 => "MfstV1",
203            RootFormat::MfstV2 => "MfstV2",
204        };
205
206        StorageInfo {
207            build_name: self.build_config.build_name.clone(),
208            product: self.build_info.product.clone(),
209            version: self.build_info.version.clone(),
210            encoding_entries: self.encoding.len(),
211            root_entries: self.root.len(),
212            root_format: root_format.to_string(),
213            index_entries: self.index.len(),
214            listfile_entries: self.listfile.len(),
215        }
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Helpers
221// ---------------------------------------------------------------------------
222
223/// Select the appropriate BuildInfo entry by product filter or auto-select.
224///
225/// When no product is specified:
226/// - If there is exactly one entry, auto-select it.
227/// - If there are multiple entries, return an error listing available products.
228///
229/// When a product is specified but not found, the error lists available products.
230fn select_build_info(entries: &[BuildInfo], product: Option<&str>) -> Result<BuildInfo> {
231    if entries.is_empty() {
232        return Err(CascError::InvalidFormat("no entries in .build.info".into()));
233    }
234
235    let selected = match product {
236        Some(p) => entries
237            .iter()
238            .find(|e| e.active && e.product == p)
239            .or_else(|| entries.iter().find(|e| e.product == p)),
240        None => {
241            if entries.len() == 1 {
242                Some(&entries[0])
243            } else {
244                entries.iter().find(|e| e.active)
245            }
246        }
247    };
248
249    selected.cloned().ok_or_else(|| {
250        let available: Vec<String> = list_products(entries)
251            .iter()
252            .map(|(name, _)| (*name).to_string())
253            .collect();
254        let available_str = available.join(", ");
255        match product {
256            Some(p) => CascError::InvalidFormat(format!(
257                "product '{}' not found. Available products: {}",
258                p, available_str
259            )),
260            None => CascError::InvalidFormat(format!(
261                "multiple products found and no product specified. Available products: {}. \
262                 Use -p <product> to select one.",
263                available_str
264            )),
265        }
266    })
267}
268
269/// Bootstrap the encoding file from the build config.
270fn bootstrap_encoding(
271    build_config: &BuildConfig,
272    index: &CascIndex,
273    data: &DataStore,
274    keystore: &TactKeyStore,
275) -> Result<EncodingFile> {
276    let ekey_bytes = hex_to_bytes(&build_config.encoding_ekey)?;
277
278    let idx_entry = index
279        .find(&ekey_bytes)
280        .ok_or_else(|| CascError::KeyNotFound {
281            key_type: "encoding EKey".into(),
282            hash: build_config.encoding_ekey.clone(),
283        })?;
284
285    let blte_data = data.read_entry(
286        idx_entry.archive_number,
287        idx_entry.archive_offset,
288        idx_entry.size,
289    )?;
290
291    let raw_data = decode_blte_with_keys(blte_data, Some(keystore))?;
292    EncodingFile::parse(&raw_data)
293}
294
295/// Bootstrap the root file via encoding lookup.
296fn bootstrap_root(
297    build_config: &BuildConfig,
298    encoding: &EncodingFile,
299    index: &CascIndex,
300    data: &DataStore,
301    keystore: &TactKeyStore,
302) -> Result<RootFile> {
303    let root_ckey = hex_to_16(&build_config.root_ckey)?;
304
305    let enc_entry = encoding
306        .find_ekey(&root_ckey)
307        .ok_or_else(|| CascError::KeyNotFound {
308            key_type: "root CKey".into(),
309            hash: build_config.root_ckey.clone(),
310        })?;
311
312    let ekey = &enc_entry.ekeys[0];
313    let idx_entry = index.find(ekey).ok_or_else(|| CascError::KeyNotFound {
314        key_type: "root EKey".into(),
315        hash: hex::encode(ekey),
316    })?;
317
318    let blte_data = data.read_entry(
319        idx_entry.archive_number,
320        idx_entry.archive_offset,
321        idx_entry.size,
322    )?;
323
324    let raw_data = decode_blte_with_keys(blte_data, Some(keystore))?;
325    RootFile::parse(&raw_data)
326}
327
328/// Load the listfile from a custom path or via download.
329fn load_listfile(config: &OpenConfig) -> Result<Listfile> {
330    if let Some(ref path) = config.listfile {
331        return Listfile::load(path);
332    }
333
334    let output_dir = config
335        .output_dir
336        .clone()
337        .unwrap_or_else(|| std::env::temp_dir().join("casc-extractor"));
338
339    load_or_download(&output_dir)
340}
341
342/// Decode a hex string to a `Vec<u8>`.
343fn hex_to_bytes(hex_str: &str) -> Result<Vec<u8>> {
344    hex::decode(hex_str)
345        .map_err(|e| CascError::InvalidFormat(format!("invalid hex string '{}': {}", hex_str, e)))
346}
347
348/// Decode a hex string to a `[u8; 16]` array.
349fn hex_to_16(hex_str: &str) -> Result<[u8; 16]> {
350    let bytes = hex_to_bytes(hex_str)?;
351    if bytes.len() < 16 {
352        return Err(CascError::InvalidFormat(format!(
353            "hex string too short for 16-byte key: '{}'",
354            hex_str
355        )));
356    }
357    let mut arr = [0u8; 16];
358    arr.copy_from_slice(&bytes[..16]);
359    Ok(arr)
360}
361
362/// Compute the output path for a FileDataID, using the listfile for naming.
363///
364/// If the listfile has a path for the FDID, the path is normalized
365/// (backslashes replaced with forward slashes) and sanitized against
366/// path traversal. Unknown files go to `output_dir/unknown/<fdid>.dat`.
367pub fn output_path(output_dir: &Path, fdid: u32, listfile: &Listfile) -> PathBuf {
368    match listfile.path(fdid) {
369        Some(path) => {
370            let normalized = path.replace('\\', "/");
371            // Prevent path traversal
372            let safe = normalized.trim_start_matches('/').trim_start_matches("../");
373            output_dir.join(safe)
374        }
375        None => output_dir.join("unknown").join(format!("{}.dat", fdid)),
376    }
377}
378
379// ---------------------------------------------------------------------------
380// Extraction functions
381// ---------------------------------------------------------------------------
382
383/// Extract all matching files from CASC storage into the output directory.
384///
385/// Files are filtered by locale and an optional glob pattern, deduplicated by
386/// FDID, sorted by archive/offset for sequential I/O, then extracted in
387/// parallel via a rayon thread pool.
388pub fn extract_all(
389    storage: &CascStorage,
390    config: &ExtractionConfig,
391    progress_cb: Option<&(dyn Fn(u64, u64) + Sync)>,
392) -> Result<ExtractionStats> {
393    // 1. Collect entries: iterate root, filter by locale
394    let locale_filter = LocaleFlags(config.locale);
395    let mut entries: Vec<(u32, &RootEntry)> = storage
396        .root
397        .iter_all()
398        .filter(|(_, entry)| entry.locale_flags.matches(locale_filter))
399        .collect();
400
401    // 2. Deduplicate by FDID (keep first matching entry per FDID)
402    let mut seen = std::collections::HashSet::new();
403    entries.retain(|(fdid, _)| seen.insert(*fdid));
404
405    // 3. Apply glob filter if present
406    if let Some(ref pattern) = config.filter {
407        entries.retain(|(fdid, _)| match storage.listfile.path(*fdid) {
408            Some(path) => glob_matches(pattern, path),
409            None => false, // unknown files don't match glob
410        });
411    }
412
413    let total = entries.len() as u64;
414
415    // 4. Sort by archive+offset for sequential I/O
416    let mut sortable: Vec<(u32, &RootEntry, u32, u64)> = entries
417        .iter()
418        .map(|(fdid, re)| {
419            let (archive, offset) = storage
420                .encoding
421                .find_ekey(&re.ckey)
422                .and_then(|ee| storage.index.find(&ee.ekeys[0]))
423                .map(|ie| (ie.archive_number, ie.archive_offset))
424                .unwrap_or((u32::MAX, u64::MAX));
425            (*fdid, *re, archive, offset)
426        })
427        .collect();
428    sortable.sort_by_key(|&(_, _, archive, offset)| (archive, offset));
429
430    // 5. Create output directory
431    std::fs::create_dir_all(&config.output_dir)?;
432
433    // 6. Create metadata writer (unless disabled)
434    let metadata = if !config.no_metadata {
435        let build_name = &storage.build_config.build_name;
436        let product = &storage.build_info.product;
437        Some(MetadataWriter::new(
438            &config.output_dir,
439            build_name,
440            product,
441        )?)
442    } else {
443        None
444    };
445
446    // 7. Set up thread pool
447    let pool = ThreadPoolBuilder::new()
448        .num_threads(config.threads)
449        .build()
450        .map_err(|e| CascError::InvalidFormat(format!("failed to create thread pool: {}", e)))?;
451
452    // 8. Parallel extraction
453    let completed = AtomicU64::new(0);
454
455    pool.install(|| {
456        sortable.par_iter().for_each(|(fdid, root_entry, _, _)| {
457            let result = extract_one(storage, config, *fdid, root_entry);
458
459            if let Some(ref meta) = metadata {
460                let entry = make_metadata_entry(*fdid, root_entry, &result, &storage.listfile);
461                let _ = meta.record(&entry);
462            }
463
464            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
465            if let Some(cb) = progress_cb {
466                cb(done, total);
467            }
468        });
469    });
470
471    // 9. Finish metadata
472    if let Some(meta) = metadata {
473        meta.finish()
474    } else {
475        Ok(ExtractionStats::default())
476    }
477}
478
479/// List all files in the root that match the given locale and optional glob
480/// filter. Returns `(FileDataID, path)` pairs sorted by FDID.
481pub fn list_files(storage: &CascStorage, locale: u32, filter: Option<&str>) -> Vec<(u32, String)> {
482    let locale_filter = LocaleFlags(locale);
483    let mut seen = std::collections::HashSet::new();
484
485    let mut result: Vec<(u32, String)> = storage
486        .root
487        .iter_all()
488        .filter(|(_, entry)| entry.locale_flags.matches(locale_filter))
489        .filter(|(fdid, _)| seen.insert(*fdid))
490        .filter(|(fdid, _)| match filter {
491            Some(pat) => match storage.listfile.path(*fdid) {
492                Some(path) => glob_matches(pat, path),
493                None => false,
494            },
495            None => true,
496        })
497        .map(|(fdid, _)| {
498            let path = storage.listfile.path(fdid).unwrap_or("unknown").to_string();
499            (fdid, path)
500        })
501        .collect();
502
503    result.sort_by_key(|(fdid, _)| *fdid);
504    result
505}
506
507/// Extract a single file by FDID or path string to the given output location.
508///
509/// The `target` parameter is parsed as a numeric FDID first; if that fails it
510/// is treated as a listfile path lookup (case-insensitive).
511pub fn extract_single_file(
512    storage: &CascStorage,
513    target: &str,
514    output: &Path,
515    locale: u32,
516) -> Result<u64> {
517    let data = if let Ok(fdid) = target.parse::<u32>() {
518        storage.read_by_fdid(fdid, LocaleFlags(locale))?
519    } else {
520        let fdid = storage
521            .listfile
522            .fdid(target)
523            .ok_or_else(|| CascError::KeyNotFound {
524                key_type: "path".into(),
525                hash: target.into(),
526            })?;
527        storage.read_by_fdid(fdid, LocaleFlags(locale))?
528    };
529
530    if let Some(parent) = output.parent() {
531        std::fs::create_dir_all(parent)?;
532    }
533    let size = data.len() as u64;
534    std::fs::write(output, &data)?;
535    Ok(size)
536}
537
538// ---------------------------------------------------------------------------
539// Extraction helpers (private)
540// ---------------------------------------------------------------------------
541
542/// Extract a single file from storage, returning the bytes written or an
543/// error string for metadata recording.
544fn extract_one(
545    storage: &CascStorage,
546    config: &ExtractionConfig,
547    fdid: u32,
548    root_entry: &RootEntry,
549) -> std::result::Result<u64, String> {
550    // Check if encrypted and skip if configured
551    if root_entry.content_flags.0 & 0x8000000 != 0 && config.skip_encrypted {
552        return Err("skipped:encrypted".into());
553    }
554
555    // Read the file data
556    let data = storage
557        .read_by_ckey(&root_entry.ckey)
558        .map_err(|e| format!("error:{}", e))?;
559
560    // Optional: verify MD5
561    if config.verify {
562        use md5::{Digest, Md5};
563        let mut hasher = Md5::new();
564        hasher.update(&data);
565        let hash = hasher.finalize();
566        if hash.as_slice() != root_entry.ckey {
567            return Err(format!("error:checksum mismatch for FDID {}", fdid));
568        }
569    }
570
571    // Determine output path and write
572    let out_path = output_path(&config.output_dir, fdid, &storage.listfile);
573
574    if let Some(parent) = out_path.parent() {
575        std::fs::create_dir_all(parent).map_err(|e| format!("error:mkdir: {}", e))?;
576    }
577
578    std::fs::write(&out_path, &data).map_err(|e| format!("error:write: {}", e))?;
579
580    Ok(data.len() as u64)
581}
582
583/// Build a [`MetadataEntry`] from extraction results for metadata recording.
584fn make_metadata_entry(
585    fdid: u32,
586    root_entry: &RootEntry,
587    result: &std::result::Result<u64, String>,
588    listfile: &Listfile,
589) -> MetadataEntry {
590    let path = listfile
591        .path(fdid)
592        .map(|s| s.to_string())
593        .unwrap_or_else(|| format!("unknown/{}.dat", fdid));
594    let ckey_hex = hex::encode(root_entry.ckey);
595
596    match result {
597        Ok(size) => MetadataEntry {
598            fdid,
599            path,
600            size: *size,
601            ckey: ckey_hex,
602            locale_flags: root_entry.locale_flags.0,
603            content_flags: root_entry.content_flags.0,
604            status: "ok".into(),
605        },
606        Err(status) => MetadataEntry {
607            fdid,
608            path,
609            size: 0,
610            ckey: ckey_hex,
611            locale_flags: root_entry.locale_flags.0,
612            content_flags: root_entry.content_flags.0,
613            status: status.clone(),
614        },
615    }
616}
617
618/// Simple glob matching for WoW file paths.
619///
620/// Supported patterns:
621/// - `dir/**` - matches all files recursively under `dir/`
622/// - `*.ext` - matches any file ending with `.ext`
623/// - `dir/*` - matches files directly inside `dir/` (not recursive)
624/// - `prefix*` or `dir/prefix*` - matches files starting with prefix
625/// - exact path - literal match
626///
627/// All matching is case-insensitive with forward-slash normalization.
628fn glob_matches(pattern: &str, path: &str) -> bool {
629    let pattern = pattern.to_lowercase().replace('\\', "/");
630    let path = path.to_lowercase().replace('\\', "/");
631
632    if pattern.ends_with("/**") {
633        let prefix = &pattern[..pattern.len() - 3];
634        return path.starts_with(&format!("{}/", prefix)) || path == *prefix;
635    }
636    if pattern.starts_with("*.") {
637        let suffix = &pattern[1..]; // e.g. ".m2"
638        return path.ends_with(suffix);
639    }
640    if pattern.ends_with("/*") {
641        let prefix = &pattern[..pattern.len() - 2];
642        return path.starts_with(&format!("{}/", prefix))
643            && !path[prefix.len() + 1..].contains('/');
644    }
645    // Trailing wildcard: "some/prefix*" matches anything starting with "some/prefix"
646    if pattern.ends_with('*') {
647        let prefix = &pattern[..pattern.len() - 1];
648        return path.starts_with(prefix);
649    }
650    // Exact match fallback
651    path == pattern
652}
653
654// ---------------------------------------------------------------------------
655// Tests
656// ---------------------------------------------------------------------------
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use std::path::PathBuf;
662
663    #[test]
664    fn output_path_from_listfile_hit() {
665        let listfile = Listfile::parse("53;Cameras/FlyBy.m2");
666        let out = PathBuf::from("/output");
667        let result = output_path(&out, 53, &listfile);
668        // Should use the listfile path (preserving case from listfile)
669        assert!(result.to_string_lossy().contains("Cameras"));
670        assert!(result.to_string_lossy().contains("FlyBy.m2"));
671    }
672
673    #[test]
674    fn output_path_from_listfile_miss() {
675        let listfile = Listfile::parse("");
676        let out = PathBuf::from("/output");
677        let result = output_path(&out, 99999, &listfile);
678        assert!(result.to_string_lossy().contains("unknown"));
679        assert!(result.to_string_lossy().contains("99999.dat"));
680    }
681
682    #[test]
683    fn output_path_normalizes_backslashes() {
684        let listfile = Listfile::parse("100;World\\Maps\\Test.adt");
685        let out = PathBuf::from("/output");
686        let result = output_path(&out, 100, &listfile);
687        // Should not contain backslashes (on the path level)
688        let s = result.to_string_lossy().replace('\\', "/");
689        assert!(s.contains("World/Maps/Test.adt") || s.contains("world/maps/test.adt"));
690    }
691
692    #[test]
693    fn output_path_prevents_traversal() {
694        // A malicious listfile entry
695        let listfile = Listfile::parse("200;../../../etc/passwd");
696        let out = PathBuf::from("/output");
697        let result = output_path(&out, 200, &listfile);
698        // Must not escape output_dir
699        assert!(result.starts_with("/output"));
700    }
701
702    #[test]
703    fn extraction_config_defaults() {
704        let config = ExtractionConfig {
705            output_dir: PathBuf::from("/out"),
706            locale: 0x2, // enUS
707            threads: 4,
708            verify: false,
709            skip_encrypted: true,
710            filter: None,
711            no_metadata: false,
712        };
713        assert_eq!(config.locale, 0x2);
714        assert!(config.skip_encrypted);
715    }
716
717    #[test]
718    fn open_config_minimal() {
719        let config = OpenConfig {
720            install_dir: PathBuf::from("E:\\World of Warcraft"),
721            product: Some("wow".into()),
722            keyfile: None,
723            listfile: None,
724            output_dir: None,
725        };
726        assert_eq!(config.product, Some("wow".into()));
727    }
728
729    #[test]
730    fn storage_info_fields() {
731        let info = StorageInfo {
732            build_name: "WOW-12345".into(),
733            product: "wow".into(),
734            version: "12.0.1.66192".into(),
735            encoding_entries: 100000,
736            root_entries: 500000,
737            root_format: "MfstV2".into(),
738            index_entries: 200000,
739            listfile_entries: 400000,
740        };
741        assert_eq!(info.build_name, "WOW-12345");
742        assert_eq!(info.root_entries, 500000);
743    }
744
745    #[test]
746    fn hex_to_bytes_16() {
747        let hex_str = "0ff1247849a5cd6049624d3a105811f8";
748        let bytes = hex::decode(hex_str).unwrap();
749        assert_eq!(bytes.len(), 16);
750        assert_eq!(bytes[0], 0x0f);
751        assert_eq!(bytes[1], 0xf1);
752    }
753
754    #[test]
755    fn hex_to_16_valid() {
756        let arr = hex_to_16("0ff1247849a5cd6049624d3a105811f8").unwrap();
757        assert_eq!(arr[0], 0x0f);
758        assert_eq!(arr[15], 0xf8);
759    }
760
761    #[test]
762    fn hex_to_16_too_short() {
763        assert!(hex_to_16("aabb").is_err());
764    }
765
766    #[test]
767    fn hex_to_16_invalid_hex() {
768        assert!(hex_to_16("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_err());
769    }
770
771    #[test]
772    fn select_build_info_by_product() {
773        let entries = vec![
774            BuildInfo {
775                branch: "eu".into(),
776                active: true,
777                build_key: "abc".into(),
778                cdn_key: "def".into(),
779                cdn_path: "".into(),
780                cdn_hosts: vec![],
781                version: "1.0".into(),
782                product: "wow".into(),
783                tags: "".into(),
784                keyring: "".into(),
785            },
786            BuildInfo {
787                branch: "eu".into(),
788                active: true,
789                build_key: "xyz".into(),
790                cdn_key: "uvw".into(),
791                cdn_path: "".into(),
792                cdn_hosts: vec![],
793                version: "2.0".into(),
794                product: "wow_classic".into(),
795                tags: "".into(),
796                keyring: "".into(),
797            },
798        ];
799
800        let selected = select_build_info(&entries, Some("wow_classic")).unwrap();
801        assert_eq!(selected.product, "wow_classic");
802        assert_eq!(selected.build_key, "xyz");
803    }
804
805    #[test]
806    fn select_build_info_first_active() {
807        let entries = vec![
808            BuildInfo {
809                branch: "eu".into(),
810                active: false,
811                build_key: "inactive".into(),
812                cdn_key: "".into(),
813                cdn_path: "".into(),
814                cdn_hosts: vec![],
815                version: "".into(),
816                product: "wow".into(),
817                tags: "".into(),
818                keyring: "".into(),
819            },
820            BuildInfo {
821                branch: "eu".into(),
822                active: true,
823                build_key: "active".into(),
824                cdn_key: "".into(),
825                cdn_path: "".into(),
826                cdn_hosts: vec![],
827                version: "1.0".into(),
828                product: "wow".into(),
829                tags: "".into(),
830                keyring: "".into(),
831            },
832        ];
833
834        let selected = select_build_info(&entries, None).unwrap();
835        assert_eq!(selected.build_key, "active");
836    }
837
838    #[test]
839    fn select_build_info_empty() {
840        let result = select_build_info(&[], Some("wow"));
841        assert!(result.is_err());
842    }
843
844    #[test]
845    fn select_build_info_no_match() {
846        let entries = vec![BuildInfo {
847            branch: "eu".into(),
848            active: true,
849            build_key: "abc".into(),
850            cdn_key: "".into(),
851            cdn_path: "".into(),
852            cdn_hosts: vec![],
853            version: "".into(),
854            product: "wow".into(),
855            tags: "".into(),
856            keyring: "".into(),
857        }];
858
859        let result = select_build_info(&entries, Some("nonexistent"));
860        assert!(result.is_err());
861        let err_msg = result.unwrap_err().to_string();
862        assert!(
863            err_msg.contains("nonexistent"),
864            "Error should mention the requested product"
865        );
866        assert!(
867            err_msg.contains("wow"),
868            "Error should list available products"
869        );
870    }
871
872    #[test]
873    fn select_build_info_auto_select_single() {
874        let entries = vec![BuildInfo {
875            branch: "eu".into(),
876            active: true,
877            build_key: "abc".into(),
878            cdn_key: "".into(),
879            cdn_path: "".into(),
880            cdn_hosts: vec![],
881            version: "1.0".into(),
882            product: "wow_classic_era".into(),
883            tags: "".into(),
884            keyring: "".into(),
885        }];
886
887        // No product specified, single entry -> auto-select
888        let selected = select_build_info(&entries, None).unwrap();
889        assert_eq!(selected.product, "wow_classic_era");
890    }
891
892    #[test]
893    fn select_build_info_error_lists_products() {
894        let entries = vec![
895            BuildInfo {
896                branch: "eu".into(),
897                active: true,
898                build_key: "abc".into(),
899                cdn_key: "".into(),
900                cdn_path: "".into(),
901                cdn_hosts: vec![],
902                version: "1.0".into(),
903                product: "wow".into(),
904                tags: "".into(),
905                keyring: "".into(),
906            },
907            BuildInfo {
908                branch: "eu".into(),
909                active: true,
910                build_key: "xyz".into(),
911                cdn_key: "".into(),
912                cdn_path: "".into(),
913                cdn_hosts: vec![],
914                version: "2.0".into(),
915                product: "wow_classic".into(),
916                tags: "".into(),
917                keyring: "".into(),
918            },
919        ];
920
921        // Product not found -> error lists available products
922        let result = select_build_info(&entries, Some("wow_classicera"));
923        assert!(result.is_err());
924        let err_msg = result.unwrap_err().to_string();
925        assert!(
926            err_msg.contains("wow_classicera"),
927            "Error should mention the requested product"
928        );
929        assert!(err_msg.contains("wow"), "Error should list 'wow'");
930        assert!(
931            err_msg.contains("wow_classic"),
932            "Error should list 'wow_classic'"
933        );
934    }
935
936    // Integration tests - only run with real WoW install
937    #[test]
938    #[ignore]
939    fn open_real_casc_storage() {
940        let config = OpenConfig {
941            install_dir: PathBuf::from(r"E:\World of Warcraft"),
942            product: Some("wow".into()),
943            keyfile: None,
944            listfile: None,
945            output_dir: Some(std::env::temp_dir().join("casc_test_open")),
946        };
947        let storage = CascStorage::open(&config).unwrap();
948        let info = storage.info();
949        assert!(info.encoding_entries > 0);
950        assert!(info.root_entries > 100000);
951        println!("Build: {}", info.build_name);
952        println!("Encoding entries: {}", info.encoding_entries);
953        println!("Root entries: {}", info.root_entries);
954    }
955
956    #[test]
957    #[ignore]
958    fn read_known_file_by_fdid() {
959        let config = OpenConfig {
960            install_dir: PathBuf::from(r"E:\World of Warcraft"),
961            product: Some("wow".into()),
962            keyfile: None,
963            listfile: None,
964            output_dir: Some(std::env::temp_dir().join("casc_test_read")),
965        };
966        let storage = CascStorage::open(&config).unwrap();
967        // FDID 1 should exist in virtually every WoW build
968        let data = storage.read_by_fdid(1, LocaleFlags::EN_US);
969        // It might fail for various reasons, but shouldn't panic
970        println!("FDID 1 result: {:?}", data.is_ok());
971        if let Ok(bytes) = data {
972            println!("FDID 1 size: {} bytes", bytes.len());
973        }
974    }
975
976    // -----------------------------------------------------------------------
977    // Glob matching tests
978    // -----------------------------------------------------------------------
979
980    #[test]
981    fn glob_matches_double_star() {
982        assert!(glob_matches(
983            "world/maps/**",
984            "world/maps/azeroth/azeroth_25_25.adt"
985        ));
986        assert!(glob_matches("world/maps/**", "world/maps/test.wdt"));
987        assert!(!glob_matches("world/maps/**", "interface/icons/test.blp"));
988    }
989
990    #[test]
991    fn glob_matches_extension() {
992        assert!(glob_matches("*.m2", "creature/bear/bear.m2"));
993        assert!(glob_matches("*.M2", "Creature/Bear/Bear.m2")); // case insensitive
994        assert!(!glob_matches("*.m2", "creature/bear/bear.skin"));
995    }
996
997    #[test]
998    fn glob_matches_single_star() {
999        assert!(glob_matches(
1000            "interface/icons/*",
1001            "interface/icons/test.blp"
1002        ));
1003        assert!(!glob_matches(
1004            "interface/icons/*",
1005            "interface/icons/subdir/test.blp"
1006        ));
1007    }
1008
1009    #[test]
1010    fn glob_matches_exact() {
1011        assert!(glob_matches("test.txt", "test.txt"));
1012        assert!(!glob_matches("test.txt", "other.txt"));
1013    }
1014
1015    // -----------------------------------------------------------------------
1016    // Extraction helper tests
1017    // -----------------------------------------------------------------------
1018
1019    #[test]
1020    fn extract_single_file_parses_fdid() {
1021        // Verify the parsing logic: numeric strings are treated as FDIDs
1022        let target = "12345";
1023        assert!(target.parse::<u32>().is_ok());
1024
1025        let target_path = "world/maps/test.adt";
1026        assert!(target_path.parse::<u32>().is_err());
1027    }
1028
1029    #[test]
1030    fn make_metadata_entry_ok() {
1031        let listfile = Listfile::parse("42;World/Test.adt");
1032        let root_entry = RootEntry {
1033            ckey: [0xAA; 16],
1034            content_flags: crate::root::flags::ContentFlags(0),
1035            locale_flags: LocaleFlags(0x2),
1036            name_hash: None,
1037        };
1038        let result: std::result::Result<u64, String> = Ok(1024);
1039        let meta = make_metadata_entry(42, &root_entry, &result, &listfile);
1040        assert_eq!(meta.fdid, 42);
1041        assert_eq!(meta.path, "World/Test.adt");
1042        assert_eq!(meta.size, 1024);
1043        assert_eq!(meta.status, "ok");
1044        assert_eq!(meta.ckey, hex::encode([0xAA; 16]));
1045    }
1046
1047    #[test]
1048    fn make_metadata_entry_error() {
1049        let listfile = Listfile::parse("");
1050        let root_entry = RootEntry {
1051            ckey: [0xBB; 16],
1052            content_flags: crate::root::flags::ContentFlags(0x8000000),
1053            locale_flags: LocaleFlags(0x2),
1054            name_hash: None,
1055        };
1056        let result: std::result::Result<u64, String> = Err("skipped:encrypted".into());
1057        let meta = make_metadata_entry(99, &root_entry, &result, &listfile);
1058        assert_eq!(meta.fdid, 99);
1059        assert_eq!(meta.path, "unknown/99.dat");
1060        assert_eq!(meta.size, 0);
1061        assert_eq!(meta.status, "skipped:encrypted");
1062    }
1063
1064    // -----------------------------------------------------------------------
1065    // Integration tests - require real WoW install
1066    // -----------------------------------------------------------------------
1067
1068    #[test]
1069    #[ignore]
1070    fn extract_all_small_filter() {
1071        let open_config = OpenConfig {
1072            install_dir: PathBuf::from(r"E:\World of Warcraft"),
1073            product: Some("wow".into()),
1074            keyfile: None,
1075            listfile: None,
1076            output_dir: Some(std::env::temp_dir().join("casc_extract_test")),
1077        };
1078        let storage = CascStorage::open(&open_config).unwrap();
1079
1080        let extract_config = ExtractionConfig {
1081            output_dir: std::env::temp_dir().join("casc_extract_test_out"),
1082            locale: 0x2, // enUS
1083            threads: 4,
1084            verify: false,
1085            skip_encrypted: true,
1086            filter: Some("*.wdt".into()),
1087            no_metadata: false,
1088        };
1089
1090        let stats = extract_all(&storage, &extract_config, None).unwrap();
1091        println!(
1092            "Extracted: {} success, {} errors, {} skipped",
1093            stats.success, stats.errors, stats.skipped
1094        );
1095        assert!(stats.total > 0);
1096    }
1097}