1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::nexus_id::{NexusFileId, NexusModId};
9use crate::resolver::GameId;
10
11fn deserialize_b64_hash<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
13 let val = serde_json::Value::deserialize(deserializer)?;
14 match &val {
15 serde_json::Value::String(s) => {
16 let bytes = BASE64.decode(s).map_err(serde::de::Error::custom)?;
17 if bytes.len() != 8 {
18 return Err(serde::de::Error::custom(format!(
19 "expected 8 bytes for hash, got {}",
20 bytes.len()
21 )));
22 }
23 Ok(u64::from_le_bytes(
24 bytes.try_into().expect("length checked to be 8 above"),
25 ))
26 }
27 serde_json::Value::Number(n) => n
28 .as_u64()
29 .ok_or_else(|| serde::de::Error::custom("hash number not a valid u64")),
30 _ => Err(serde::de::Error::custom(
31 "expected string or number for hash",
32 )),
33 }
34}
35
36fn deserialize_headers<'de, D: Deserializer<'de>>(
38 deserializer: D,
39) -> Result<HashMap<String, String>, D::Error> {
40 let val = serde_json::Value::deserialize(deserializer)?;
41 match val {
42 serde_json::Value::Object(map) => {
43 let mut result = HashMap::new();
44 for (k, v) in map {
45 if let Some(s) = v.as_str() {
46 result.insert(k, s.to_string());
47 }
48 }
49 Ok(result)
50 }
51 serde_json::Value::Array(_) => Ok(HashMap::new()),
52 serde_json::Value::Null => Ok(HashMap::new()),
53 _ => Err(serde::de::Error::custom(
54 "expected object or array for Headers",
55 )),
56 }
57}
58
59fn serialize_b64_hash<S: Serializer>(val: &u64, serializer: S) -> Result<S::Ok, S::Error> {
61 let encoded = BASE64.encode(val.to_le_bytes());
62 serializer.serialize_str(&encoded)
63}
64
65#[must_use]
67pub fn parse_b64_hash(s: &str) -> Option<u64> {
68 let bytes = BASE64.decode(s).ok()?;
69 if bytes.len() != 8 {
70 return None;
71 }
72 Some(u64::from_le_bytes(
73 bytes.try_into().expect("length checked to be 8 above"),
74 ))
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(rename_all = "PascalCase")]
80pub struct WabbajackManifest {
81 pub name: String,
82 pub author: String,
83 pub description: String,
84 #[serde(alias = "GameType")]
85 pub game: String,
86 pub version: String,
87 #[serde(default)]
88 pub archives: Vec<ArchiveEntry>,
89 #[serde(default)]
90 pub directives: Vec<RawDirective>,
91}
92
93#[must_use]
104pub fn compute_manifest_hash(manifest: &WabbajackManifest) -> String {
105 use std::hash::{Hash, Hasher};
106 let mut hasher = std::collections::hash_map::DefaultHasher::new();
107 manifest.name.hash(&mut hasher);
108 manifest.version.hash(&mut hasher);
109 format!("{:x}", hasher.finish())
110}
111
112pub fn cache_wabbajack_file(source: &Path, manifest_hash: &str) -> crate::error::Result<PathBuf> {
121 let dest = crate::paths::wabbajack_cache_path(manifest_hash);
122 if dest.exists() {
123 return Ok(dest);
124 }
125 if let Some(parent) = dest.parent() {
126 std::fs::create_dir_all(parent)?;
127 }
128 std::fs::copy(source, &dest)?;
129 Ok(dest)
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "PascalCase")]
135pub struct ArchiveEntry {
136 #[serde(
137 deserialize_with = "deserialize_b64_hash",
138 serialize_with = "serialize_b64_hash"
139 )]
140 pub hash: u64,
141 pub name: String,
142 pub size: u64,
143 #[serde(default)]
144 pub state: Option<ArchiveState>,
145}
146
147impl ArchiveEntry {
148 #[must_use]
150 pub fn download_directive(&self) -> Option<DownloadDirective> {
151 let state = self.state.as_ref()?;
152 Some(match state {
153 ArchiveState::NexusDownloader {
154 game_name,
155 mod_id,
156 file_id,
157 } => DownloadDirective::Nexus {
158 game_id: GameId::from(game_name.clone()),
159 mod_id: *mod_id,
160 file_id: *file_id,
161 hash: self.hash,
162 },
163 ArchiveState::GitHubDownloader {
164 user,
165 repo,
166 tag,
167 asset,
168 } => DownloadDirective::GitHub {
169 user: user.clone(),
170 repo: repo.clone(),
171 tag: tag.clone(),
172 asset: asset.clone(),
173 hash: self.hash,
174 },
175 ArchiveState::GoogleDriveDownloader { id } => DownloadDirective::GoogleDrive {
176 id: id.clone(),
177 hash: self.hash,
178 },
179 ArchiveState::MegaDownloader { url } => DownloadDirective::Mega {
180 url: url.clone(),
181 hash: self.hash,
182 },
183 ArchiveState::MediaFireDownloader { url } => DownloadDirective::MediaFire {
184 url: url.clone(),
185 hash: self.hash,
186 },
187 ArchiveState::ManualDownloader { url, prompt } => DownloadDirective::Manual {
188 url: url.clone(),
189 prompt: prompt.clone(),
190 hash: self.hash,
191 expected_name: self.name.clone(),
192 },
193 ArchiveState::HttpDownloader { url, headers } => DownloadDirective::DirectURL {
194 url: url.clone(),
195 headers: headers.clone(),
196 mirror_resolver: None,
197 hash: self.hash,
198 },
199 ArchiveState::ModDBDownloader { url, .. } => DownloadDirective::DirectURL {
200 url: url.clone(),
201 headers: HashMap::new(),
202 mirror_resolver: moddb_html_mirror_resolver(url),
203 hash: self.hash,
204 },
205 ArchiveState::WabbajackCDNDownloader { metadata } => DownloadDirective::WabbajackCdn {
206 url: wabbajack_cdn_url(metadata)?,
207 hash: self.hash,
208 },
209 ArchiveState::GameFileSourceDownloader { .. } => return None,
210 })
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(tag = "$type")]
217pub enum ArchiveState {
218 #[serde(alias = "NexusDownloader, Wabbajack.Lib")]
219 NexusDownloader {
220 #[serde(rename = "GameName")]
221 game_name: String,
222 #[serde(rename = "ModID")]
223 mod_id: NexusModId,
224 #[serde(rename = "FileID")]
225 file_id: NexusFileId,
226 },
227 #[serde(alias = "GitHubDownloader, Wabbajack.Lib")]
228 GitHubDownloader {
229 #[serde(rename = "User")]
230 user: String,
231 #[serde(rename = "Repo")]
232 repo: String,
233 #[serde(rename = "Tag")]
234 tag: String,
235 #[serde(rename = "Asset")]
236 asset: String,
237 },
238 #[serde(alias = "GoogleDriveDownloader, Wabbajack.Lib")]
239 GoogleDriveDownloader {
240 #[serde(rename = "Id")]
241 id: String,
242 },
243 #[serde(alias = "MegaDownloader, Wabbajack.Lib")]
244 MegaDownloader {
245 #[serde(rename = "Url")]
246 url: String,
247 },
248 #[serde(alias = "MediaFireDownloader+State, Wabbajack.Lib")]
249 MediaFireDownloader {
250 #[serde(rename = "Url")]
251 url: String,
252 },
253 #[serde(alias = "ManualDownloader, Wabbajack.Lib")]
254 ManualDownloader {
255 #[serde(rename = "Url")]
256 url: String,
257 #[serde(default, rename = "Prompt")]
258 prompt: String,
259 },
260 #[serde(alias = "HttpDownloader, Wabbajack.Lib")]
261 HttpDownloader {
262 #[serde(rename = "Url")]
263 url: String,
264 #[serde(default, rename = "Headers", deserialize_with = "deserialize_headers")]
265 headers: HashMap<String, String>,
266 },
267 #[serde(alias = "ModDBDownloader, Wabbajack.Lib")]
268 ModDBDownloader {
269 #[serde(rename = "Url")]
270 url: String,
271 #[serde(flatten)]
272 metadata: HashMap<String, serde_json::Value>,
273 },
274 #[serde(alias = "GameFileSourceDownloader, Wabbajack.Lib")]
275 GameFileSourceDownloader {
276 #[serde(flatten)]
277 metadata: HashMap<String, serde_json::Value>,
278 },
279 #[serde(alias = "WabbajackCDNDownloader+State, Wabbajack.Lib")]
280 WabbajackCDNDownloader {
281 #[serde(flatten)]
282 metadata: HashMap<String, serde_json::Value>,
283 },
284}
285
286impl ArchiveState {
287 #[must_use]
293 pub fn game_file_path(&self) -> Option<&str> {
294 let Self::GameFileSourceDownloader { metadata } = self else {
295 return None;
296 };
297
298 [
299 "File",
300 "FilePath",
301 "GameFile",
302 "GameFilePath",
303 "Path",
304 "RelativePath",
305 ]
306 .into_iter()
307 .find_map(|key| metadata.get(key).and_then(serde_json::Value::as_str))
308 }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(tag = "$type")]
314pub enum RawDirective {
315 #[serde(alias = "FromArchive, Wabbajack.Lib")]
316 FromArchive {
317 #[serde(rename = "ArchiveHashPath")]
318 archive_hash_path: Vec<serde_json::Value>,
319 #[serde(rename = "To")]
320 to: String,
321 #[serde(default, rename = "Size")]
322 size: u64,
323 },
324 #[serde(alias = "InlineFile, Wabbajack.Lib")]
325 InlineFile {
326 #[serde(
327 rename = "Hash",
328 deserialize_with = "deserialize_b64_hash",
329 serialize_with = "serialize_b64_hash"
330 )]
331 hash: u64,
332 #[serde(rename = "Size")]
333 size: u64,
334 #[serde(rename = "SourceDataID")]
335 source_data_id: String,
336 #[serde(rename = "To")]
337 to: String,
338 },
339 #[serde(alias = "RemappedInlineFile, Wabbajack.Lib")]
340 RemappedInlineFile {
341 #[serde(
342 rename = "Hash",
343 deserialize_with = "deserialize_b64_hash",
344 serialize_with = "serialize_b64_hash"
345 )]
346 hash: u64,
347 #[serde(rename = "Size")]
348 size: u64,
349 #[serde(rename = "SourceDataID")]
350 source_data_id: String,
351 #[serde(rename = "To")]
352 to: String,
353 },
354 #[serde(alias = "PatchedFromArchive, Wabbajack.Lib")]
355 PatchedFromArchive {
356 #[serde(rename = "ArchiveHashPath")]
357 archive_hash_path: Vec<serde_json::Value>,
358 #[serde(rename = "To")]
359 to: String,
360 #[serde(
361 rename = "Hash",
362 deserialize_with = "deserialize_b64_hash",
363 serialize_with = "serialize_b64_hash"
364 )]
365 hash: u64,
366 #[serde(rename = "PatchID")]
367 patch_id: String,
368 #[serde(default, rename = "Size")]
369 size: u64,
370 },
371 #[serde(alias = "CreateBSA, Wabbajack.Lib")]
372 CreateBSA {
373 #[serde(rename = "TempID")]
374 temp_id: String,
375 #[serde(rename = "To")]
376 to: String,
377 #[serde(default, rename = "FileStates")]
378 file_states: Vec<BSAFileState>,
379 },
380 #[serde(other)]
381 Unknown,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
386pub enum DownloadDirective {
387 Nexus {
388 game_id: GameId,
389 mod_id: NexusModId,
390 file_id: NexusFileId,
391 hash: u64,
392 },
393 GitHub {
394 user: String,
395 repo: String,
396 tag: String,
397 asset: String,
398 hash: u64,
399 },
400 GoogleDrive {
401 id: String,
402 hash: u64,
403 },
404 Mega {
405 url: String,
406 hash: u64,
407 },
408 MediaFire {
409 url: String,
410 hash: u64,
411 },
412 Manual {
413 url: String,
414 prompt: String,
415 hash: u64,
416 expected_name: String,
417 },
418 DirectURL {
419 url: String,
420 headers: HashMap<String, String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 mirror_resolver: Option<HtmlMirrorResolver>,
423 hash: u64,
424 },
425 WabbajackCdn {
426 url: String,
427 hash: u64,
428 },
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct HtmlMirrorResolver {
435 pub name: String,
436 pub original_url: String,
437 pub listing_url: String,
438 pub link_id: String,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub user_agent: Option<String>,
441}
442
443fn truncate_str(s: &str, max: usize) -> &str {
446 let mut end = s.len().min(max);
447 while !s.is_char_boundary(end) {
448 end -= 1;
449 }
450 &s[..end]
451}
452
453impl DownloadDirective {
454 #[must_use]
456 pub fn hash(&self) -> u64 {
457 match self {
458 Self::Nexus { hash, .. }
459 | Self::GitHub { hash, .. }
460 | Self::GoogleDrive { hash, .. }
461 | Self::Mega { hash, .. }
462 | Self::MediaFire { hash, .. }
463 | Self::Manual { hash, .. }
464 | Self::DirectURL { hash, .. }
465 | Self::WabbajackCdn { hash, .. } => *hash,
466 }
467 }
468
469 #[must_use]
475 pub fn display_name(&self) -> Cow<'_, str> {
476 match self {
477 Self::Nexus { mod_id, .. } => format!("nexus:{mod_id}").into(),
478 Self::GitHub { repo, .. } => format!("github:{repo}").into(),
479 Self::GoogleDrive { id, .. } => format!("gdrive:{id}").into(),
480 Self::Mega { url, .. } => format!("mega:{}", truncate_str(url, 30)).into(),
481 Self::MediaFire { url, .. } => format!("mediafire:{}", truncate_str(url, 40)).into(),
482 Self::Manual { expected_name, .. } => format!("manual:{expected_name}").into(),
483 Self::DirectURL { url, .. } => format!("http:{}", truncate_str(url, 30)).into(),
484 Self::WabbajackCdn { url, .. } => {
485 format!("wabbajack-cdn:{}", truncate_str(url, 30)).into()
486 }
487 }
488 }
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
493pub enum InstallDirective {
494 FromArchive {
495 archive_hash: u64,
496 from: String,
497 inner_path: Option<String>,
498 to: String,
499 size: u64,
500 },
501 InlineFile {
502 source_data_id: String,
503 to: String,
504 },
505 PatchedFromArchive {
506 archive_hash: u64,
507 from: String,
508 inner_path: Option<String>,
509 to: String,
510 patch_id: String,
511 size: u64,
512 },
513 CreateBSA {
514 temp_id: String,
515 to: String,
516 file_states: Vec<BSAFileState>,
517 },
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct IndexedInstallDirective {
523 pub directive_index: usize,
524 pub directive: InstallDirective,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ArchiveInstallBatch {
530 pub archive_hash: u64,
531 pub archive_size_bytes: u64,
532 pub directives: Vec<IndexedInstallDirective>,
533}
534
535impl InstallDirective {
536 fn source_archive_hash(&self) -> Option<u64> {
537 match self {
538 Self::FromArchive { archive_hash, .. }
539 | Self::PatchedFromArchive { archive_hash, .. } => Some(*archive_hash),
540 Self::InlineFile { .. } | Self::CreateBSA { .. } => None,
541 }
542 }
543
544 fn source_inner_path(&self) -> &str {
545 match self {
546 Self::FromArchive { from, .. } | Self::PatchedFromArchive { from, .. } => from,
547 Self::InlineFile { source_data_id, .. } => source_data_id,
548 Self::CreateBSA { temp_id, .. } => temp_id,
549 }
550 }
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize)]
555#[serde(rename_all = "PascalCase")]
556pub struct BSAFileState {
557 pub path: String,
558 #[serde(
559 default,
560 deserialize_with = "deserialize_b64_hash",
561 serialize_with = "serialize_b64_hash"
562 )]
563 pub hash: u64,
564 #[serde(default)]
565 pub size: u64,
566}
567
568fn parse_hash_value(val: Option<&serde_json::Value>) -> u64 {
570 val.and_then(|v| v.as_str().and_then(parse_b64_hash).or_else(|| v.as_u64()))
571 .unwrap_or(0)
572}
573
574impl WabbajackManifest {
575 #[must_use]
577 pub fn download_directives(&self) -> Vec<DownloadDirective> {
578 self.archives
579 .iter()
580 .filter_map(ArchiveEntry::download_directive)
581 .collect()
582 }
583
584 #[must_use]
586 pub fn install_directives(&self) -> Vec<InstallDirective> {
587 self.directives
588 .iter()
589 .filter_map(|d| match d {
590 RawDirective::FromArchive {
591 archive_hash_path,
592 to,
593 size,
594 } => {
595 let hash = parse_hash_value(archive_hash_path.first());
596 let from = archive_hash_path
597 .get(1)
598 .and_then(|v| v.as_str())
599 .unwrap_or("")
600 .to_string();
601 let inner_path = archive_hash_path
602 .get(2)
603 .and_then(|v| v.as_str())
604 .map(ToString::to_string);
605 Some(InstallDirective::FromArchive {
606 archive_hash: hash,
607 from,
608 inner_path,
609 to: to.clone(),
610 size: *size,
611 })
612 }
613 RawDirective::InlineFile {
614 source_data_id, to, ..
615 }
616 | RawDirective::RemappedInlineFile {
617 source_data_id, to, ..
618 } => Some(InstallDirective::InlineFile {
619 source_data_id: source_data_id.clone(),
620 to: to.clone(),
621 }),
622 RawDirective::PatchedFromArchive {
623 archive_hash_path,
624 to,
625 patch_id,
626 size,
627 ..
628 } => {
629 let archive_hash = parse_hash_value(archive_hash_path.first());
630 let from = archive_hash_path
631 .get(1)
632 .and_then(|v| v.as_str())
633 .unwrap_or("")
634 .to_string();
635 let inner_path = archive_hash_path
636 .get(2)
637 .and_then(|v| v.as_str())
638 .map(ToString::to_string);
639 Some(InstallDirective::PatchedFromArchive {
640 archive_hash,
641 from,
642 inner_path,
643 to: to.clone(),
644 patch_id: patch_id.clone(),
645 size: *size,
646 })
647 }
648 RawDirective::CreateBSA {
649 temp_id,
650 to,
651 file_states,
652 } => Some(InstallDirective::CreateBSA {
653 temp_id: temp_id.clone(),
654 to: to.clone(),
655 file_states: file_states.clone(),
656 }),
657 RawDirective::Unknown => None,
658 })
659 .collect()
660 }
661
662 #[must_use]
665 pub fn install_directives_grouped_by_archive(&self) -> Vec<ArchiveInstallBatch> {
666 let archive_size_by_hash: HashMap<u64, u64> =
667 self.archives.iter().map(|a| (a.hash, a.size)).collect();
668 let mut by_archive: HashMap<u64, Vec<IndexedInstallDirective>> = HashMap::new();
669
670 for (directive_index, directive) in self.install_directives().into_iter().enumerate() {
671 let Some(archive_hash) = directive.source_archive_hash() else {
672 continue;
673 };
674 by_archive
675 .entry(archive_hash)
676 .or_default()
677 .push(IndexedInstallDirective {
678 directive_index,
679 directive,
680 });
681 }
682
683 let mut batches = by_archive
684 .into_iter()
685 .map(|(archive_hash, mut directives)| {
686 directives.sort_by(|a, b| {
687 a.directive
688 .source_inner_path()
689 .cmp(b.directive.source_inner_path())
690 });
691 ArchiveInstallBatch {
692 archive_hash,
693 archive_size_bytes: archive_size_by_hash
694 .get(&archive_hash)
695 .copied()
696 .unwrap_or_default(),
697 directives,
698 }
699 })
700 .collect::<Vec<_>>();
701
702 batches.sort_by_key(|batch| {
703 batch
704 .directives
705 .iter()
706 .map(|directive| directive.directive_index)
707 .min()
708 .unwrap_or(usize::MAX)
709 });
710 batches
711 }
712}
713
714fn moddb_html_mirror_resolver(url: &str) -> Option<HtmlMirrorResolver> {
715 let id = moddb_download_id(url)?;
716 Some(HtmlMirrorResolver {
717 name: "moddb-html-mirror".to_string(),
718 original_url: url.to_string(),
719 listing_url: format!("https://www.moddb.com/downloads/start/{id}/all"),
720 link_id: "downloadon".to_string(),
721 user_agent: Some("Wabbajack/4.0 modde".to_string()),
722 })
723}
724
725fn wabbajack_cdn_url(metadata: &HashMap<String, serde_json::Value>) -> Option<String> {
726 metadata
727 .get("Url")
728 .and_then(serde_json::Value::as_str)
729 .filter(|url| !url.is_empty())
730 .map(str::to_string)
731}
732
733fn moddb_download_id(url: &str) -> Option<&str> {
734 let rest = url.split_once("/downloads/start/")?.1;
735 let id_len = rest
736 .char_indices()
737 .take_while(|(_, ch)| ch.is_ascii_digit())
738 .map(|(idx, ch)| idx + ch.len_utf8())
739 .last()
740 .unwrap_or(0);
741 if id_len == 0 {
742 None
743 } else {
744 Some(&rest[..id_len])
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 #[test]
753 fn truncate_str_never_splits_a_codepoint() {
754 let s = format!("x{}", "€".repeat(20));
756 assert!(!s.is_char_boundary(30));
757 let t = truncate_str(&s, 30);
758 assert!(t.len() <= 30 && s.is_char_boundary(t.len()));
759 assert_eq!(truncate_str("abc", 30), "abc");
760 }
761
762 #[test]
763 fn display_name_does_not_panic_on_multibyte_url() {
764 let url = format!("https://mega.nz/{}", "€".repeat(20));
765 let _ = DownloadDirective::Mega { url, hash: 0 }.display_name();
766 }
767
768 #[test]
769 fn game_file_source_downloader_parses_and_is_not_downloaded() {
770 let json = r#"{
771 "Name": "Legends of the Frost synthetic",
772 "Author": "test",
773 "Description": "test",
774 "Game": "SkyrimSE",
775 "Version": "1.0.0",
776 "Archives": [
777 {
778 "Hash": "AQAAAAAAAAA=",
779 "Name": "Data_Skyrim.esm",
780 "Size": 1024,
781 "State": {
782 "$type": "GameFileSourceDownloader, Wabbajack.Lib",
783 "File": "Data\\Skyrim.esm"
784 }
785 },
786 {
787 "Hash": "AgAAAAAAAAA=",
788 "Name": "mod.zip",
789 "Size": 2048,
790 "State": {
791 "$type": "HttpDownloader, Wabbajack.Lib",
792 "Url": "https://example.invalid/mod.zip",
793 "Headers": []
794 }
795 }
796 ],
797 "Directives": [
798 {
799 "$type": "FromArchive, Wabbajack.Lib",
800 "ArchiveHashPath": ["AQAAAAAAAAA="],
801 "To": "mods/Skyrim Base/Skyrim.esm"
802 }
803 ]
804 }"#;
805
806 let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
807 let source = manifest.archives[0].state.as_ref().unwrap();
808 assert_eq!(source.game_file_path(), Some("Data\\Skyrim.esm"));
809
810 let downloads = manifest.download_directives();
811 assert_eq!(downloads.len(), 1);
812 assert_eq!(downloads[0].hash(), 2);
813
814 let installs = manifest.install_directives();
815 assert!(matches!(
816 installs.as_slice(),
817 [InstallDirective::FromArchive {
818 archive_hash: 1,
819 from,
820 to,
821 ..
822 }] if from.is_empty() && to == "mods/Skyrim Base/Skyrim.esm"
823 ));
824 }
825
826 #[test]
827 fn wabbajack_cdn_downloader_parses_as_authored_download() {
828 let json = r#"{
829 "Name": "Legends of the Frost synthetic",
830 "Author": "test",
831 "Description": "test",
832 "Game": "SkyrimSE",
833 "Version": "1.0.0",
834 "Archives": [
835 {
836 "Hash": "AQAAAAAAAAA=",
837 "Name": "Legends of the Frost - Generated Output.7z",
838 "Size": 1024,
839 "State": {
840 "$type": "WabbajackCDNDownloader+State, Wabbajack.Lib",
841 "Url": "https://authored-files.wabbajack.org/Generated%20Output.7z_abc",
842 "MungedName": "Generated Output.7z_abc"
843 }
844 },
845 {
846 "Hash": "AgAAAAAAAAA=",
847 "Name": "mod.zip",
848 "Size": 2048,
849 "State": {
850 "$type": "HttpDownloader, Wabbajack.Lib",
851 "Url": "https://example.invalid/mod.zip",
852 "Headers": []
853 }
854 }
855 ],
856 "Directives": []
857 }"#;
858
859 let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
860 assert!(matches!(
861 manifest.archives[0].state.as_ref(),
862 Some(ArchiveState::WabbajackCDNDownloader { metadata })
863 if metadata.get("MungedName").and_then(serde_json::Value::as_str)
864 == Some("Generated Output.7z_abc")
865 ));
866
867 let downloads = manifest.download_directives();
868 assert_eq!(downloads.len(), 2);
869 assert!(matches!(
870 &downloads[0],
871 DownloadDirective::WabbajackCdn { url, hash }
872 if url == "https://authored-files.wabbajack.org/Generated%20Output.7z_abc"
873 && *hash == 1
874 ));
875 assert_eq!(downloads[1].hash(), 2);
876 }
877
878 #[test]
879 fn moddb_downloader_parses_as_direct_url_download() {
880 let json = r#"{
881 "Name": "Legends of the Frost synthetic",
882 "Author": "test",
883 "Description": "test",
884 "Game": "SkyrimSE",
885 "Version": "1.0.0",
886 "Archives": [
887 {
888 "Hash": "AQAAAAAAAAA=",
889 "Name": "Skyrim_Realistic_Overhaul_Part_1.7z",
890 "Size": 1024,
891 "State": {
892 "$type": "ModDBDownloader, Wabbajack.Lib",
893 "Url": "https://www.moddb.com/downloads/start/116891",
894 "PrimaryKeyString": "ModDBDownloader+State|https://www.moddb.com/downloads/start/116891"
895 }
896 }
897 ],
898 "Directives": []
899 }"#;
900
901 let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
902 assert!(matches!(
903 manifest.archives[0].state.as_ref(),
904 Some(ArchiveState::ModDBDownloader { url, metadata })
905 if url == "https://www.moddb.com/downloads/start/116891"
906 && metadata.contains_key("PrimaryKeyString")
907 ));
908
909 let downloads = manifest.download_directives();
910 assert!(matches!(
911 downloads.as_slice(),
912 [DownloadDirective::DirectURL {
913 url,
914 headers,
915 mirror_resolver,
916 hash
917 }]
918 if url == "https://www.moddb.com/downloads/start/116891"
919 && headers.is_empty()
920 && mirror_resolver.as_ref().is_some_and(|resolver| {
921 resolver.name == "moddb-html-mirror"
922 && resolver.original_url == "https://www.moddb.com/downloads/start/116891"
923 && resolver.listing_url == "https://www.moddb.com/downloads/start/116891/all"
924 && resolver.link_id == "downloadon"
925 && resolver.user_agent.as_deref() == Some("Wabbajack/4.0 modde")
926 })
927 && *hash == 1
928 ));
929 }
930
931 #[test]
932 fn moddb_downloader_derives_mirror_listing_url_with_query_string() {
933 assert_eq!(
934 moddb_download_id("https://www.moddb.com/downloads/start/116927?referer=x"),
935 Some("116927")
936 );
937 let resolver =
938 moddb_html_mirror_resolver("https://www.moddb.com/downloads/start/116927?referer=x")
939 .unwrap();
940 assert_eq!(
941 resolver.listing_url,
942 "https://www.moddb.com/downloads/start/116927/all"
943 );
944 }
945
946 #[test]
947 fn create_bsa_file_state_hash_defaults_when_absent() {
948 let json = r#"{
949 "Name": "Legends of the Frost synthetic",
950 "Author": "test",
951 "Description": "test",
952 "Game": "SkyrimSE",
953 "Version": "1.0.0",
954 "Archives": [],
955 "Directives": [
956 {
957 "$type": "CreateBSA, Wabbajack.Lib",
958 "TempID": "textures.bsa",
959 "To": "mods/Generated/textures.bsa",
960 "FileStates": [
961 {
962 "$type": "BSAFileState, Compression.BSA",
963 "FlipCompression": false,
964 "Index": 0,
965 "Path": "textures\\architecture\\riften\\riftenrope01.dds"
966 }
967 ]
968 }
969 ]
970 }"#;
971
972 let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
973 let installs = manifest.install_directives();
974 assert!(matches!(
975 installs.as_slice(),
976 [InstallDirective::CreateBSA { file_states, .. }]
977 if file_states.len() == 1
978 && file_states[0].path == "textures\\architecture\\riften\\riftenrope01.dds"
979 && file_states[0].hash == 0
980 ));
981 }
982
983 #[test]
984 fn remapped_inline_file_parses_as_inline_install_directive() {
985 let json = r#"{
986 "Name": "Twisted synthetic",
987 "Author": "test",
988 "Description": "test",
989 "Game": "SkyrimSpecialEdition",
990 "Version": "1.0.0",
991 "Archives": [],
992 "Directives": [
993 {
994 "$type": "RemappedInlineFile",
995 "Hash": "H6Wy/QKDBVE=",
996 "Size": 6022,
997 "SourceDataID": "db027c84-eb75-4852-ae01-72cc75abe3a1",
998 "To": "mods\\BodySlide and Outfit Studio\\CalienteTools\\BodySlide\\Config.xml"
999 }
1000 ]
1001 }"#;
1002
1003 let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
1004 let installs = manifest.install_directives();
1005 assert!(matches!(
1006 installs.as_slice(),
1007 [InstallDirective::InlineFile { source_data_id, to }]
1008 if source_data_id == "db027c84-eb75-4852-ae01-72cc75abe3a1"
1009 && to == "mods\\BodySlide and Outfit Studio\\CalienteTools\\BodySlide\\Config.xml"
1010 ));
1011 }
1012
1013 #[test]
1014 fn install_directives_grouped_by_archive_returns_one_batch_per_archive() {
1015 let manifest = WabbajackManifest {
1016 name: "batch synthetic".into(),
1017 author: "test".into(),
1018 description: "test".into(),
1019 game: "SkyrimSE".into(),
1020 version: "1.0.0".into(),
1021 archives: vec![
1022 ArchiveEntry {
1023 hash: 10,
1024 name: "a.7z".into(),
1025 size: 1024,
1026 state: None,
1027 },
1028 ArchiveEntry {
1029 hash: 20,
1030 name: "b.7z".into(),
1031 size: 2048,
1032 state: None,
1033 },
1034 ],
1035 directives: vec![
1036 RawDirective::FromArchive {
1037 archive_hash_path: vec![
1038 serde_json::Value::Number(10.into()),
1039 serde_json::Value::String("z-last.txt".into()),
1040 ],
1041 to: "mods/a/z-last.txt".into(),
1042 size: 0,
1043 },
1044 RawDirective::InlineFile {
1045 hash: 0,
1046 size: 1,
1047 source_data_id: "inline".into(),
1048 to: "mods/inline.txt".into(),
1049 },
1050 RawDirective::PatchedFromArchive {
1051 archive_hash_path: vec![
1052 serde_json::Value::Number(20.into()),
1053 serde_json::Value::String("only.txt".into()),
1054 ],
1055 to: "mods/b/only.txt".into(),
1056 hash: 0,
1057 patch_id: "patch".into(),
1058 size: 123,
1059 },
1060 RawDirective::FromArchive {
1061 archive_hash_path: vec![
1062 serde_json::Value::Number(10.into()),
1063 serde_json::Value::String("a-first.txt".into()),
1064 ],
1065 to: "mods/a/a-first.txt".into(),
1066 size: 0,
1067 },
1068 RawDirective::CreateBSA {
1069 temp_id: "temp".into(),
1070 to: "mods/out.bsa".into(),
1071 file_states: vec![],
1072 },
1073 ],
1074 };
1075
1076 let batches = manifest.install_directives_grouped_by_archive();
1077 assert_eq!(batches.len(), 2);
1078 assert_eq!(batches[0].archive_hash, 10);
1079 assert_eq!(batches[0].archive_size_bytes, 1024);
1080 assert_eq!(batches[0].directives.len(), 2);
1081 assert_eq!(batches[0].directives[0].directive_index, 3);
1082 assert!(matches!(
1083 &batches[0].directives[0].directive,
1084 InstallDirective::FromArchive { from, .. } if from == "a-first.txt"
1085 ));
1086 assert_eq!(batches[0].directives[1].directive_index, 0);
1087 assert!(matches!(
1088 &batches[1].directives[..],
1089 [IndexedInstallDirective {
1090 directive_index: 2,
1091 directive: InstallDirective::PatchedFromArchive {
1092 archive_hash: 20,
1093 size: 123,
1094 ..
1095 }
1096 }]
1097 ));
1098 }
1099}