Skip to main content

modde_sources/mega/
mod.rs

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
23/// Mega.nz download source.
24///
25/// Handles Mega's client-side AES-128-CTR decryption protocol.
26pub struct MegaSource {
27    client: Client,
28}
29
30#[derive(Debug, Deserialize)]
31struct MegaFileResponse {
32    /// Download URL
33    g: String,
34    /// File size
35    s: u64,
36}
37
38/// Parse a Mega URL to extract the file handle and key.
39/// Supports both new format `/file/HANDLE#KEY` and old format `/#!HANDLE!KEY`.
40fn parse_mega_url(url: &str) -> Result<(String, String)> {
41    // New format: https://mega.nz/file/HANDLE#KEY
42    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    // Old format: https://mega.nz/#!HANDLE!KEY
53    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
64/// Decode a Mega key from base64url, XOR first/second halves for AES-128 key, extract IV.
65fn 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    // XOR first 16 bytes with second 16 bytes to get AES key
78    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    // IV is bytes 16..24, zero-padded to 16 bytes (counter starts at 0)
84    let mut iv = [0u8; 16];
85    iv[..8].copy_from_slice(&key_bytes[16..24]);
86    // bytes 8..16 of IV are zero (counter)
87
88    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        // Call Mega API to get download URL
110        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    // ── parse_mega_url: new format /file/HANDLE#KEY ──────────────────────
191
192    #[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        // base64url uses - and _ instead of + and /
224        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    // ── parse_mega_url: old format /#!HANDLE!KEY ─────────────────────────
231
232    #[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        // The old-format parser uses find("#!"), so it works even with odd prefixes
251        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    // ── parse_mega_url: invalid URLs ─────────────────────────────────────
258
259    #[test]
260    fn parse_url_no_hash_new_format() {
261        // Missing # separator in new format
262        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        // "splitn(2, '#')" produces ["HANDLE", ""] – length 2 but empty key
283        // The function does not reject empty keys, so this succeeds with empty key
284        let result = parse_mega_url("https://mega.nz/file/HANDLE#");
285        // Regardless of success/failure, document behaviour
286        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        // Query params end up as part of the key (since we only split on #)
294        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        // "/file/" prefix is stripped, then everything up to # is handle
303        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    // ── decode_mega_key: valid 32-byte keys ──────────────────────────────
310
311    #[test]
312    fn decode_key_valid_32_bytes() {
313        // 32 zero bytes -> base64url = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
314        // URL_SAFE_NO_PAD expects no padding, so use the unpadded form
315        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        // 0 XOR 0 = 0 for all bytes
319        assert_eq!(aes_key, [0u8; 16]);
320        // IV = bytes 16..24 of original (all zero), zero-padded
321        assert_eq!(iv, [0u8; 16]);
322    }
323
324    #[test]
325    fn decode_key_xor_logic() {
326        // Construct a 32-byte key where first half = [1..=16], second half = [17..=32]
327        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        // Verify XOR: aes_key[i] = key_bytes[i] ^ key_bytes[i+16]
336        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        // Verify IV: bytes 16..24 of original, zero-padded to 16
345        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        // If first half == second half, XOR yields all zeros
353        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        // first half = 0xFF, second half = 0x00 -> XOR = 0xFF
366        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    // ── decode_mega_key: IV extraction correctness ───────────────────────
376
377    #[test]
378    fn decode_key_iv_extraction() {
379        let mut key_bytes = [0u8; 32];
380        // Set bytes 16..24 to distinct values
381        for i in 0..8 {
382            key_bytes[16 + i] = (0x10 + i) as u8;
383        }
384        // Set bytes 24..32 to something else (should NOT appear in IV)
385        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        // First 8 bytes of IV = bytes 16..24 of original
392        for i in 0..8 {
393            assert_eq!(iv[i], (0x10 + i) as u8, "IV byte {i} mismatch");
394        }
395        // Last 8 bytes of IV must be zero (counter)
396        for i in 8..16 {
397            assert_eq!(iv[i], 0, "IV counter byte {i} should be zero");
398        }
399    }
400
401    // ── decode_mega_key: invalid inputs ──────────────────────────────────
402
403    #[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    // ── decode_mega_key: very long key string ────────────────────────────
461
462    #[test]
463    fn decode_key_very_long_base64() {
464        // 256 bytes is way too long
465        let long = URL_SAFE_NO_PAD.encode([0xABu8; 256]);
466        assert!(decode_mega_key(&long).is_err());
467    }
468
469    // ── can_handle ───────────────────────────────────────────────────────
470
471    #[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}