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
10fn 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
31fn 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
52fn 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
58pub 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#[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
83pub 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
101pub 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#[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#[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#[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#[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 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 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#[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#[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
339fn 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 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 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}