Skip to main content

modde_core/manifest/
wabbajack.rs

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::resolver::GameId;
9
10/// Deserialize a Wabbajack hash — accepts base64 string or plain integer.
11fn deserialize_b64_hash<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
12    let val = serde_json::Value::deserialize(deserializer)?;
13    match &val {
14        serde_json::Value::String(s) => {
15            let bytes = BASE64.decode(s).map_err(serde::de::Error::custom)?;
16            if bytes.len() != 8 {
17                return Err(serde::de::Error::custom(format!(
18                    "expected 8 bytes for hash, got {}",
19                    bytes.len()
20                )));
21            }
22            Ok(u64::from_le_bytes(bytes.try_into().unwrap()))
23        }
24        serde_json::Value::Number(n) => n
25            .as_u64()
26            .ok_or_else(|| serde::de::Error::custom("hash number not a valid u64")),
27        _ => Err(serde::de::Error::custom("expected string or number for hash")),
28    }
29}
30
31/// Deserialize Headers field — Wabbajack uses `[]` (empty array) not `{}` (empty object).
32fn deserialize_headers<'de, D: Deserializer<'de>>(
33    deserializer: D,
34) -> Result<HashMap<String, String>, D::Error> {
35    let val = serde_json::Value::deserialize(deserializer)?;
36    match val {
37        serde_json::Value::Object(map) => {
38            let mut result = HashMap::new();
39            for (k, v) in map {
40                if let Some(s) = v.as_str() {
41                    result.insert(k, s.to_string());
42                }
43            }
44            Ok(result)
45        }
46        serde_json::Value::Array(_) => Ok(HashMap::new()),
47        serde_json::Value::Null => Ok(HashMap::new()),
48        _ => Err(serde::de::Error::custom("expected object or array for Headers")),
49    }
50}
51
52/// Serialize a u64 hash back to Wabbajack base64 format.
53fn serialize_b64_hash<S: Serializer>(val: &u64, serializer: S) -> Result<S::Ok, S::Error> {
54    let encoded = BASE64.encode(val.to_le_bytes());
55    serializer.serialize_str(&encoded)
56}
57
58/// Parse a base64 hash string into u64 (for use outside serde).
59pub fn parse_b64_hash(s: &str) -> Option<u64> {
60    let bytes = BASE64.decode(s).ok()?;
61    if bytes.len() != 8 {
62        return None;
63    }
64    Some(u64::from_le_bytes(bytes.try_into().unwrap()))
65}
66
67/// Top-level manifest from a `.wabbajack` archive (which is a zip containing JSON).
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "PascalCase")]
70pub struct WabbajackManifest {
71    pub name: String,
72    pub author: String,
73    pub description: String,
74    #[serde(alias = "GameType")]
75    pub game: String,
76    pub version: String,
77    #[serde(default)]
78    pub archives: Vec<ArchiveEntry>,
79    #[serde(default)]
80    pub directives: Vec<RawDirective>,
81}
82
83/// Compute a stable identifier for a Wabbajack manifest, derived from its
84/// `name` + `version`. Used as the `manifest_hash` field on
85/// [`crate::profile::LockReason::Wabbajack`] so install and retroactive-scan
86/// flows produce identical IDs for the same modlist.
87///
88/// The hashing scheme is `DefaultHasher::hash(name) + hash(version)` rendered
89/// in lowercase hex — matching the scheme previously inlined at
90/// `crates/modde-cli/src/commands/install.rs:361-367`. Extracted here so
91/// `scan --manifest` can produce bit-identical hashes during retroactive
92/// lock assignment.
93pub fn compute_manifest_hash(manifest: &WabbajackManifest) -> String {
94    use std::hash::{Hash, Hasher};
95    let mut hasher = std::collections::hash_map::DefaultHasher::new();
96    manifest.name.hash(&mut hasher);
97    manifest.version.hash(&mut hasher);
98    format!("{:x}", hasher.finish())
99}
100
101/// Copy a `.wabbajack` source file into the content-addressed cache so a
102/// [`crate::profile::LockReason::Wabbajack`] record can be self-verifying even
103/// if the original source moves or is deleted.
104///
105/// Idempotent: if the destination already exists, returns its path without
106/// re-copying — `manifest_hash` is a stable content identifier, so two
107/// different source files that share a hash are treated as equivalent.
108/// Creates the cache directory on demand.
109pub fn cache_wabbajack_file(source: &Path, manifest_hash: &str) -> crate::error::Result<PathBuf> {
110    let dest = crate::paths::wabbajack_cache_path(manifest_hash);
111    if dest.exists() {
112        return Ok(dest);
113    }
114    if let Some(parent) = dest.parent() {
115        std::fs::create_dir_all(parent)?;
116    }
117    std::fs::copy(source, &dest)?;
118    Ok(dest)
119}
120
121/// An archive entry referenced by hash in download/install directives.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "PascalCase")]
124pub struct ArchiveEntry {
125    #[serde(
126        deserialize_with = "deserialize_b64_hash",
127        serialize_with = "serialize_b64_hash"
128    )]
129    pub hash: u64,
130    pub name: String,
131    pub size: u64,
132    #[serde(default)]
133    pub state: Option<ArchiveState>,
134}
135
136/// Source-specific metadata for an archive.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(tag = "$type")]
139pub enum ArchiveState {
140    #[serde(alias = "NexusDownloader, Wabbajack.Lib")]
141    NexusDownloader {
142        #[serde(rename = "GameName")]
143        game_name: String,
144        #[serde(rename = "ModID")]
145        mod_id: u64,
146        #[serde(rename = "FileID")]
147        file_id: u64,
148    },
149    #[serde(alias = "GitHubDownloader, Wabbajack.Lib")]
150    GitHubDownloader {
151        #[serde(rename = "User")]
152        user: String,
153        #[serde(rename = "Repo")]
154        repo: String,
155        #[serde(rename = "Tag")]
156        tag: String,
157        #[serde(rename = "Asset")]
158        asset: String,
159    },
160    #[serde(alias = "GoogleDriveDownloader, Wabbajack.Lib")]
161    GoogleDriveDownloader {
162        #[serde(rename = "Id")]
163        id: String,
164    },
165    #[serde(alias = "MegaDownloader, Wabbajack.Lib")]
166    MegaDownloader {
167        #[serde(rename = "Url")]
168        url: String,
169    },
170    #[serde(alias = "HttpDownloader, Wabbajack.Lib")]
171    HttpDownloader {
172        #[serde(rename = "Url")]
173        url: String,
174        #[serde(
175            default,
176            rename = "Headers",
177            deserialize_with = "deserialize_headers"
178        )]
179        headers: HashMap<String, String>,
180    },
181}
182
183/// A raw directive from the manifest, before we convert to our typed enums.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(tag = "$type")]
186pub enum RawDirective {
187    #[serde(alias = "FromArchive, Wabbajack.Lib")]
188    FromArchive {
189        #[serde(rename = "ArchiveHashPath")]
190        archive_hash_path: Vec<serde_json::Value>,
191        #[serde(rename = "To")]
192        to: String,
193    },
194    #[serde(alias = "InlineFile, Wabbajack.Lib")]
195    InlineFile {
196        #[serde(
197            rename = "Hash",
198            deserialize_with = "deserialize_b64_hash",
199            serialize_with = "serialize_b64_hash"
200        )]
201        hash: u64,
202        #[serde(rename = "Size")]
203        size: u64,
204        #[serde(rename = "SourceDataID")]
205        source_data_id: String,
206        #[serde(rename = "To")]
207        to: String,
208    },
209    #[serde(alias = "PatchedFromArchive, Wabbajack.Lib")]
210    PatchedFromArchive {
211        #[serde(rename = "ArchiveHashPath")]
212        archive_hash_path: Vec<serde_json::Value>,
213        #[serde(rename = "To")]
214        to: String,
215        #[serde(
216            rename = "Hash",
217            deserialize_with = "deserialize_b64_hash",
218            serialize_with = "serialize_b64_hash"
219        )]
220        hash: u64,
221        #[serde(rename = "PatchID")]
222        patch_id: String,
223    },
224    #[serde(alias = "CreateBSA, Wabbajack.Lib")]
225    CreateBSA {
226        #[serde(rename = "TempID")]
227        temp_id: String,
228        #[serde(rename = "To")]
229        to: String,
230        #[serde(default, rename = "FileStates")]
231        file_states: Vec<BSAFileState>,
232    },
233    #[serde(other)]
234    Unknown,
235}
236
237/// Our typed download directive enum for downstream consumers.
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub enum DownloadDirective {
240    Nexus {
241        game_id: GameId,
242        mod_id: u64,
243        file_id: u64,
244        hash: u64,
245    },
246    GitHub {
247        user: String,
248        repo: String,
249        tag: String,
250        asset: String,
251        hash: u64,
252    },
253    GoogleDrive {
254        id: String,
255        hash: u64,
256    },
257    Mega {
258        url: String,
259        hash: u64,
260    },
261    DirectURL {
262        url: String,
263        headers: HashMap<String, String>,
264        hash: u64,
265    },
266}
267
268impl DownloadDirective {
269    /// Extract the expected hash from any directive variant.
270    pub fn hash(&self) -> u64 {
271        match self {
272            Self::Nexus { hash, .. }
273            | Self::GitHub { hash, .. }
274            | Self::GoogleDrive { hash, .. }
275            | Self::Mega { hash, .. }
276            | Self::DirectURL { hash, .. } => *hash,
277        }
278    }
279
280    /// Human-readable label for progress/error messages.
281    ///
282    /// Returns `Cow::Borrowed` for variants where the label can be
283    /// computed without allocation (currently none, but future-proofed),
284    /// and `Cow::Owned` when formatting is required.
285    pub fn display_name(&self) -> Cow<'_, str> {
286        match self {
287            Self::Nexus { mod_id, .. } => format!("nexus:{mod_id}").into(),
288            Self::GitHub { repo, .. } => format!("github:{repo}").into(),
289            Self::GoogleDrive { id, .. } => format!("gdrive:{id}").into(),
290            Self::Mega { url, .. } => {
291                format!("mega:{}", &url[..url.len().min(30)]).into()
292            }
293            Self::DirectURL { url, .. } => {
294                format!("http:{}", &url[..url.len().min(30)]).into()
295            }
296        }
297    }
298}
299
300/// Our typed install directive enum.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub enum InstallDirective {
303    FromArchive {
304        archive_hash: u64,
305        from: String,
306        to: String,
307    },
308    InlineFile {
309        source_data_id: String,
310        to: String,
311    },
312    PatchedFromArchive {
313        archive_hash: u64,
314        from: String,
315        to: String,
316        patch_id: String,
317    },
318    CreateBSA {
319        temp_id: String,
320        to: String,
321        file_states: Vec<BSAFileState>,
322    },
323}
324
325/// State for a file inside a BSA/BA2 archive.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(rename_all = "PascalCase")]
328pub struct BSAFileState {
329    pub path: String,
330    #[serde(
331        deserialize_with = "deserialize_b64_hash",
332        serialize_with = "serialize_b64_hash"
333    )]
334    pub hash: u64,
335    #[serde(default)]
336    pub size: u64,
337}
338
339/// Parse a hash from a serde_json::Value — tries base64 string first, then numeric.
340fn parse_hash_value(val: Option<&serde_json::Value>) -> u64 {
341    val.and_then(|v| {
342        v.as_str()
343            .and_then(parse_b64_hash)
344            .or_else(|| v.as_u64())
345    })
346    .unwrap_or(0)
347}
348
349impl WabbajackManifest {
350    /// Extract typed download directives from archive entries.
351    pub fn download_directives(&self) -> Vec<DownloadDirective> {
352        self.archives
353            .iter()
354            .filter_map(|archive| {
355                let state = archive.state.as_ref()?;
356                Some(match state {
357                    ArchiveState::NexusDownloader {
358                        game_name,
359                        mod_id,
360                        file_id,
361                    } => DownloadDirective::Nexus {
362                        game_id: GameId::from(game_name.clone()),
363                        mod_id: *mod_id,
364                        file_id: *file_id,
365                        hash: archive.hash,
366                    },
367                    ArchiveState::GitHubDownloader {
368                        user,
369                        repo,
370                        tag,
371                        asset,
372                    } => DownloadDirective::GitHub {
373                        user: user.clone(),
374                        repo: repo.clone(),
375                        tag: tag.clone(),
376                        asset: asset.clone(),
377                        hash: archive.hash,
378                    },
379                    ArchiveState::GoogleDriveDownloader { id } => DownloadDirective::GoogleDrive {
380                        id: id.clone(),
381                        hash: archive.hash,
382                    },
383                    ArchiveState::MegaDownloader { url } => DownloadDirective::Mega {
384                        url: url.clone(),
385                        hash: archive.hash,
386                    },
387                    ArchiveState::HttpDownloader { url, headers } => DownloadDirective::DirectURL {
388                        url: url.clone(),
389                        headers: headers.clone(),
390                        hash: archive.hash,
391                    },
392                })
393            })
394            .collect()
395    }
396
397    /// Extract typed install directives from raw directives.
398    pub fn install_directives(&self) -> Vec<InstallDirective> {
399        self.directives
400            .iter()
401            .filter_map(|d| match d {
402                RawDirective::FromArchive {
403                    archive_hash_path,
404                    to,
405                } => {
406                    let hash = parse_hash_value(archive_hash_path.first());
407                    let from = archive_hash_path
408                        .get(1)
409                        .and_then(|v| v.as_str())
410                        .unwrap_or("")
411                        .to_string();
412                    Some(InstallDirective::FromArchive {
413                        archive_hash: hash,
414                        from,
415                        to: to.clone(),
416                    })
417                }
418                RawDirective::InlineFile {
419                    source_data_id,
420                    to,
421                    ..
422                } => Some(InstallDirective::InlineFile {
423                    source_data_id: source_data_id.clone(),
424                    to: to.clone(),
425                }),
426                RawDirective::PatchedFromArchive {
427                    archive_hash_path,
428                    to,
429                    patch_id,
430                    ..
431                } => {
432                    let archive_hash = parse_hash_value(archive_hash_path.first());
433                    let from = archive_hash_path
434                        .get(1)
435                        .and_then(|v| v.as_str())
436                        .unwrap_or("")
437                        .to_string();
438                    Some(InstallDirective::PatchedFromArchive {
439                        archive_hash,
440                        from,
441                        to: to.clone(),
442                        patch_id: patch_id.clone(),
443                    })
444                }
445                RawDirective::CreateBSA {
446                    temp_id,
447                    to,
448                    file_states,
449                } => Some(InstallDirective::CreateBSA {
450                    temp_id: temp_id.clone(),
451                    to: to.clone(),
452                    file_states: file_states.clone(),
453                }),
454                RawDirective::Unknown => None,
455            })
456            .collect()
457    }
458}