1use std::collections::HashMap;
5use std::path::Path;
6
7use aes::Aes128;
8use anyhow::{Context, Result, bail};
9use base64::Engine;
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11use ctr::Ctr128BE;
12use ctr::cipher::{KeyIvInit, StreamCipher};
13use futures::StreamExt;
14use reqwest::Client;
15use serde::Deserialize;
16use tokio::io::AsyncWriteExt;
17use tracing::debug;
18
19use modde_core::manifest::wabbajack::DownloadDirective;
20
21use crate::common::{ensure_parent, verify_and_wrap};
22use crate::error::{SourceError, SourceResult, status_error};
23use crate::traits::{DownloadHandle, DownloadSource, ProgressCallback, VerifiedFile};
24
25const MEGA_API_URL: &str = "https://g.api.mega.co.nz/cs";
26
27pub struct MegaSource {
31 client: Client,
32}
33
34#[derive(Debug, Deserialize)]
35struct MegaFileResponse {
36 g: String,
38 s: u64,
40}
41
42fn parse_mega_url(url: &str) -> Result<(String, String)> {
45 if let Some(rest) = url
47 .strip_prefix("https://mega.nz/file/")
48 .or_else(|| url.strip_prefix("http://mega.nz/file/"))
49 {
50 let parts: Vec<&str> = rest.splitn(2, '#').collect();
51 if parts.len() == 2 {
52 return Ok((parts[0].to_string(), parts[1].to_string()));
53 }
54 }
55
56 if let Some(rest) = url.find("#!") {
58 let fragment = &url[rest + 2..];
59 let parts: Vec<&str> = fragment.splitn(2, '!').collect();
60 if parts.len() == 2 {
61 return Ok((parts[0].to_string(), parts[1].to_string()));
62 }
63 }
64
65 bail!("invalid Mega URL format: {url}")
66}
67
68fn decode_mega_key(key_b64: &str) -> Result<([u8; 16], [u8; 16])> {
70 let key_bytes = URL_SAFE_NO_PAD
71 .decode(key_b64)
72 .context("failed to decode Mega key from base64url")?;
73
74 if key_bytes.len() != 32 {
75 bail!("expected 32-byte Mega key, got {} bytes", key_bytes.len());
76 }
77
78 let mut aes_key = [0u8; 16];
80 for i in 0..16 {
81 aes_key[i] = key_bytes[i] ^ key_bytes[i + 16];
82 }
83
84 let mut iv = [0u8; 16];
86 iv[..8].copy_from_slice(&key_bytes[16..24]);
87 Ok((aes_key, iv))
90}
91
92impl MegaSource {
93 #[must_use]
95 pub fn new(client: Client) -> Self {
96 Self { client }
97 }
98}
99
100impl DownloadSource for MegaSource {
101 fn can_handle(&self, directive: &DownloadDirective) -> bool {
102 matches!(directive, DownloadDirective::Mega { .. })
103 }
104
105 async fn resolve(&self, directive: &DownloadDirective) -> SourceResult<DownloadHandle> {
106 let DownloadDirective::Mega { url, hash } = directive else {
107 return Err(SourceError::other(anyhow::anyhow!("not a Mega directive")));
108 };
109
110 let (handle_id, key_b64) = parse_mega_url(url).map_err(SourceError::other)?;
111
112 let api_url = format!("{MEGA_API_URL}?id=0");
114 let payload = serde_json::json!([{"a": "g", "g": 1, "p": handle_id}]);
115
116 let resp = status_error(self.client.post(&api_url).json(&payload).send().await?)?;
117
118 let body: Vec<MegaFileResponse> = resp.json().await?;
119 let file_info = body
120 .into_iter()
121 .next()
122 .ok_or_else(|| SourceError::other(anyhow::anyhow!("empty response from Mega API")))?;
123
124 debug!(download_url = %file_info.g, size = file_info.s, "resolved Mega download URL");
125
126 let mut headers = HashMap::new();
127 headers.insert("x-mega-key".to_string(), key_b64);
128
129 Ok(DownloadHandle {
130 url: file_info.g,
131 candidate_urls: Vec::new(),
132 headers,
133 expected_hash: *hash,
134 size_hint: Some(file_info.s),
135 })
136 }
137
138 async fn download_with_progress(
139 &self,
140 handle: DownloadHandle,
141 dest: &Path,
142 progress: ProgressCallback,
143 ) -> SourceResult<VerifiedFile> {
144 ensure_parent(dest).await?;
145
146 let key_b64 = handle
147 .headers
148 .get("x-mega-key")
149 .ok_or_else(|| {
150 SourceError::other(anyhow::anyhow!(
151 "missing x-mega-key header in download handle"
152 ))
153 })?
154 .clone();
155
156 let (aes_key, iv) = decode_mega_key(&key_b64).map_err(SourceError::other)?;
157
158 let resp = status_error(self.client.get(&handle.url).send().await?)?;
159
160 let total = resp.content_length().or(handle.size_hint).unwrap_or(0);
161 let mut file = tokio::fs::File::create(dest).await?;
162 let mut downloaded: u64 = 0;
163
164 let mut cipher = Ctr128BE::<Aes128>::new(&aes_key.into(), &iv.into());
165
166 let mut stream = resp.bytes_stream();
167 while let Some(chunk) = stream.next().await {
168 let mut chunk = chunk?.to_vec();
169 cipher.apply_keystream(&mut chunk);
170 file.write_all(&chunk).await?;
171 downloaded += chunk.len() as u64;
172 progress(downloaded, total);
173 }
174
175 file.flush().await?;
176 debug!(bytes = downloaded, "Mega download complete");
177
178 verify_and_wrap(dest, handle.expected_hash).await
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use modde_core::GameId;
186
187 #[test]
190 fn parse_new_format_https() {
191 let (handle, key) = parse_mega_url("https://mega.nz/file/ABC123#some_key_base64").unwrap();
192 assert_eq!(handle, "ABC123");
193 assert_eq!(key, "some_key_base64");
194 }
195
196 #[test]
197 fn parse_new_format_http() {
198 let (handle, key) = parse_mega_url("http://mega.nz/file/XYZ789#another_key").unwrap();
199 assert_eq!(handle, "XYZ789");
200 assert_eq!(key, "another_key");
201 }
202
203 #[test]
204 fn parse_new_format_long_handle_and_key() {
205 let (handle, key) = parse_mega_url(
206 "https://mega.nz/file/AbCdEfGhIjKlMnOp#AAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCCDDDDDDDDDDDD",
207 )
208 .unwrap();
209 assert_eq!(handle, "AbCdEfGhIjKlMnOp");
210 assert_eq!(key, "AAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCCDDDDDDDDDDDD");
211 }
212
213 #[test]
214 fn parse_new_format_key_with_special_base64url_chars() {
215 let (handle, key) =
217 parse_mega_url("https://mega.nz/file/HANDLE#a-b_c-d_e-f_g-h_i-j_k").unwrap();
218 assert_eq!(handle, "HANDLE");
219 assert_eq!(key, "a-b_c-d_e-f_g-h_i-j_k");
220 }
221
222 #[test]
225 fn parse_old_format_https() {
226 let (handle, key) = parse_mega_url("https://mega.nz/#!ABC123!some_key_base64").unwrap();
227 assert_eq!(handle, "ABC123");
228 assert_eq!(key, "some_key_base64");
229 }
230
231 #[test]
232 fn parse_old_format_http() {
233 let (handle, key) = parse_mega_url("http://mega.nz/#!OldHandle!OldKey123").unwrap();
234 assert_eq!(handle, "OldHandle");
235 assert_eq!(key, "OldKey123");
236 }
237
238 #[test]
239 fn parse_old_format_with_extra_prefix() {
240 let (handle, key) = parse_mega_url("https://mega.co.nz/#!HANDLE!KEY").unwrap();
242 assert_eq!(handle, "HANDLE");
243 assert_eq!(key, "KEY");
244 }
245
246 #[test]
249 fn parse_url_no_hash_new_format() {
250 assert!(parse_mega_url("https://mega.nz/file/ABCnohash").is_err());
252 }
253
254 #[test]
255 fn parse_url_random_url() {
256 assert!(parse_mega_url("https://example.com/file").is_err());
257 }
258
259 #[test]
260 fn parse_url_empty_string() {
261 assert!(parse_mega_url("").is_err());
262 }
263
264 #[test]
265 fn parse_url_only_domain() {
266 assert!(parse_mega_url("https://mega.nz").is_err());
267 }
268
269 #[test]
270 fn parse_url_no_key_after_hash() {
271 let result = parse_mega_url("https://mega.nz/file/HANDLE#");
274 if let Ok((_handle, key)) = &result {
276 assert!(key.is_empty());
277 }
278 }
279
280 #[test]
281 fn parse_url_with_query_params_new_format() {
282 let (handle, key) = parse_mega_url("https://mega.nz/file/HANDLE#KEY?foo=bar").unwrap();
284 assert_eq!(handle, "HANDLE");
285 assert_eq!(key, "KEY?foo=bar");
286 }
287
288 #[test]
289 fn parse_url_with_extra_path_segments() {
290 let (handle, key) = parse_mega_url("https://mega.nz/file/HANDLE/extra#KEY").unwrap();
292 assert_eq!(handle, "HANDLE/extra");
293 assert_eq!(key, "KEY");
294 }
295
296 #[test]
299 fn decode_key_valid_32_bytes() {
300 let key_bytes = [0u8; 32];
303 let key_b64 = URL_SAFE_NO_PAD.encode(key_bytes);
304 let (aes_key, iv) = decode_mega_key(&key_b64).unwrap();
305 assert_eq!(aes_key, [0u8; 16]);
307 assert_eq!(iv, [0u8; 16]);
309 }
310
311 #[test]
312 fn decode_key_xor_logic() {
313 let mut key_bytes = [0u8; 32];
315 for i in 0..16 {
316 key_bytes[i] = (i + 1) as u8;
317 key_bytes[i + 16] = (i + 17) as u8;
318 }
319 let key_b64 = URL_SAFE_NO_PAD.encode(key_bytes);
320 let (aes_key, iv) = decode_mega_key(&key_b64).unwrap();
321
322 for i in 0..16 {
324 assert_eq!(
325 aes_key[i],
326 key_bytes[i] ^ key_bytes[i + 16],
327 "XOR mismatch at index {i}"
328 );
329 }
330
331 let mut expected_iv = [0u8; 16];
333 expected_iv[..8].copy_from_slice(&key_bytes[16..24]);
334 assert_eq!(iv, expected_iv);
335 }
336
337 #[test]
338 fn decode_key_xor_inverse() {
339 let mut key_bytes = [0u8; 32];
341 for byte in key_bytes.iter_mut().take(16) {
342 *byte = 0xAB;
343 }
344 for byte in key_bytes.iter_mut().skip(16) {
345 *byte = 0xAB;
346 }
347 let key_b64 = URL_SAFE_NO_PAD.encode(key_bytes);
348 let (aes_key, _iv) = decode_mega_key(&key_b64).unwrap();
349 assert_eq!(aes_key, [0u8; 16]);
350 }
351
352 #[test]
353 fn decode_key_xor_all_ones() {
354 let mut key_bytes = [0u8; 32];
356 for byte in key_bytes.iter_mut().take(16) {
357 *byte = 0xFF;
358 }
359 let key_b64 = URL_SAFE_NO_PAD.encode(key_bytes);
360 let (aes_key, _iv) = decode_mega_key(&key_b64).unwrap();
361 assert_eq!(aes_key, [0xFF; 16]);
362 }
363
364 #[test]
367 fn decode_key_iv_extraction() {
368 let mut key_bytes = [0u8; 32];
369 for i in 0..8 {
371 key_bytes[16 + i] = (0x10 + i) as u8;
372 }
373 for i in 0..8 {
375 key_bytes[24 + i] = 0xFF;
376 }
377 let key_b64 = URL_SAFE_NO_PAD.encode(key_bytes);
378 let (_aes_key, iv) = decode_mega_key(&key_b64).unwrap();
379
380 for (i, byte) in iv.iter().enumerate().take(8) {
382 assert_eq!(*byte, (0x10 + i) as u8, "IV byte {i} mismatch");
383 }
384 for (i, byte) in iv.iter().enumerate().skip(8) {
386 assert_eq!(*byte, 0, "IV counter byte {i} should be zero");
387 }
388 }
389
390 #[test]
393 fn decode_key_too_short() {
394 let short = URL_SAFE_NO_PAD.encode([0u8; 16]);
395 let err = decode_mega_key(&short).unwrap_err();
396 assert!(
397 err.to_string().contains("expected 32-byte"),
398 "unexpected error: {err}"
399 );
400 }
401
402 #[test]
403 fn decode_key_too_long() {
404 let long = URL_SAFE_NO_PAD.encode([0u8; 48]);
405 let err = decode_mega_key(&long).unwrap_err();
406 assert!(
407 err.to_string().contains("expected 32-byte"),
408 "unexpected error: {err}"
409 );
410 }
411
412 #[test]
413 fn decode_key_empty() {
414 let err = decode_mega_key("").unwrap_err();
415 assert!(
416 err.to_string().contains("expected 32-byte"),
417 "unexpected error: {err}"
418 );
419 }
420
421 #[test]
422 fn decode_key_invalid_base64() {
423 let err = decode_mega_key("!!!not-valid-base64!!!").unwrap_err();
424 assert!(
425 err.to_string().contains("base64"),
426 "unexpected error: {err}"
427 );
428 }
429
430 #[test]
431 fn decode_key_one_byte() {
432 let one = URL_SAFE_NO_PAD.encode([0x42u8; 1]);
433 let err = decode_mega_key(&one).unwrap_err();
434 assert!(err.to_string().contains("expected 32-byte"));
435 }
436
437 #[test]
438 fn decode_key_31_bytes() {
439 let data = URL_SAFE_NO_PAD.encode([0u8; 31]);
440 assert!(decode_mega_key(&data).is_err());
441 }
442
443 #[test]
444 fn decode_key_33_bytes() {
445 let data = URL_SAFE_NO_PAD.encode([0u8; 33]);
446 assert!(decode_mega_key(&data).is_err());
447 }
448
449 #[test]
452 fn decode_key_very_long_base64() {
453 let long = URL_SAFE_NO_PAD.encode([0xABu8; 256]);
455 assert!(decode_mega_key(&long).is_err());
456 }
457
458 #[test]
461 fn can_handle_mega_directive() {
462 let source = MegaSource::new(Client::new());
463 let directive = DownloadDirective::Mega {
464 url: "https://mega.nz/file/ABC#KEY".to_string(),
465 hash: 0,
466 };
467 assert!(source.can_handle(&directive));
468 }
469
470 #[test]
471 fn can_handle_rejects_nexus() {
472 let source = MegaSource::new(Client::new());
473 let directive = DownloadDirective::Nexus {
474 game_id: GameId::from("skyrim"),
475 mod_id: 1.into(),
476 file_id: 1.into(),
477 hash: 0,
478 };
479 assert!(!source.can_handle(&directive));
480 }
481
482 #[test]
483 fn can_handle_rejects_google_drive() {
484 let source = MegaSource::new(Client::new());
485 let directive = DownloadDirective::GoogleDrive {
486 id: "some-id".to_string(),
487 hash: 0,
488 };
489 assert!(!source.can_handle(&directive));
490 }
491
492 #[test]
493 fn can_handle_rejects_github() {
494 let source = MegaSource::new(Client::new());
495 let directive = DownloadDirective::GitHub {
496 user: "user".to_string(),
497 repo: "repo".to_string(),
498 tag: "v1".to_string(),
499 asset: "file.zip".to_string(),
500 hash: 0,
501 };
502 assert!(!source.can_handle(&directive));
503 }
504
505 #[test]
506 fn can_handle_rejects_direct_url() {
507 let source = MegaSource::new(Client::new());
508 let directive = DownloadDirective::DirectURL {
509 url: "https://example.com/file.zip".to_string(),
510 headers: HashMap::new(),
511 mirror_resolver: None,
512 hash: 0,
513 };
514 assert!(!source.can_handle(&directive));
515 }
516}