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