1use 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
29pub struct OpenConfig {
35 pub install_dir: PathBuf,
37 pub product: Option<String>,
39 pub keyfile: Option<PathBuf>,
41 pub listfile: Option<PathBuf>,
43 pub output_dir: Option<PathBuf>,
45}
46
47pub struct ExtractionConfig {
49 pub output_dir: PathBuf,
51 pub locale: u32,
53 pub threads: usize,
55 pub verify: bool,
57 pub skip_encrypted: bool,
59 pub filter: Option<String>,
61 pub no_metadata: bool,
63}
64
65pub struct StorageInfo {
67 pub build_name: String,
69 pub product: String,
71 pub version: String,
73 pub encoding_entries: usize,
75 pub root_entries: usize,
77 pub root_format: String,
79 pub index_entries: usize,
81 pub listfile_entries: usize,
83}
84
85pub 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 pub fn open(config: &OpenConfig) -> Result<Self> {
107 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 let build_info = select_build_info(&all_entries, config.product.as_deref())?;
114
115 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 let data_data_dir = data_dir.join("data");
124 let index = CascIndex::load(&data_data_dir)?;
125
126 let data_store = DataStore::open(&data_data_dir)?;
128
129 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 let encoding = bootstrap_encoding(&build_config, &index, &data_store, &keystore)?;
138
139 let root = bootstrap_root(&build_config, &encoding, &index, &data_store, &keystore)?;
141
142 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 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 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 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
219fn 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
269fn 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
295fn 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
328fn 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
342fn 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
348fn 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
362pub 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 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
379pub fn extract_all(
389 storage: &CascStorage,
390 config: &ExtractionConfig,
391 progress_cb: Option<&(dyn Fn(u64, u64) + Sync)>,
392) -> Result<ExtractionStats> {
393 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 let mut seen = std::collections::HashSet::new();
403 entries.retain(|(fdid, _)| seen.insert(*fdid));
404
405 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, });
411 }
412
413 let total = entries.len() as u64;
414
415 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 std::fs::create_dir_all(&config.output_dir)?;
432
433 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 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 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 if let Some(meta) = metadata {
473 meta.finish()
474 } else {
475 Ok(ExtractionStats::default())
476 }
477}
478
479pub 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
507pub 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
538fn extract_one(
545 storage: &CascStorage,
546 config: &ExtractionConfig,
547 fdid: u32,
548 root_entry: &RootEntry,
549) -> std::result::Result<u64, String> {
550 if root_entry.content_flags.0 & 0x8000000 != 0 && config.skip_encrypted {
552 return Err("skipped:encrypted".into());
553 }
554
555 let data = storage
557 .read_by_ckey(&root_entry.ckey)
558 .map_err(|e| format!("error:{}", e))?;
559
560 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 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
583fn 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
618fn 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..]; 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 if pattern.ends_with('*') {
647 let prefix = &pattern[..pattern.len() - 1];
648 return path.starts_with(prefix);
649 }
650 path == pattern
652}
653
654#[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 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 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 let listfile = Listfile::parse("200;../../../etc/passwd");
696 let out = PathBuf::from("/output");
697 let result = output_path(&out, 200, &listfile);
698 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, 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 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 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 #[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 let data = storage.read_by_fdid(1, LocaleFlags::EN_US);
969 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 #[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")); 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 #[test]
1020 fn extract_single_file_parses_fdid() {
1021 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 #[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, 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}