Skip to main content

wechat_protocol/
media.rs

1//! Bounded native CDN transport. This uses the observed HTTP carrier on port 443,
2//! not HTTPS. Media encryption and advertised size/digests are checked separately.
3use crate::{
4    crypto::{md5, random, sha256},
5    protobuf as p, AccountState,
6};
7use anyhow::{bail, ensure, Result};
8use openssl::{
9    hash::{Hasher, MessageDigest},
10    symm::{Cipher, Crypter, Mode},
11};
12use serde::{Deserialize, Serialize};
13use std::{collections::BTreeMap, fmt, io::Read, net::Ipv4Addr, time::Duration};
14use zeroize::Zeroize;
15pub type CdnFields = BTreeMap<String, Vec<u8>>;
16const MAX_CDN_BODY: usize = 2 * 1024 * 1024;
17const UPLOAD_CHUNK: usize = 65536;
18pub const MAX_CDN_TRANSFER_BYTES: usize = 25 * 1024 * 1024;
19pub const MAX_NATIVE_UPLOAD_BYTES: usize = 25 * 1024 * 1024 - 17;
20pub const MAX_LARGE_FILE_BYTES: usize = wechat_media::MAX_FILE_BYTES;
21#[path = "large_cdn_config.rs"]
22mod large_cdn_config;
23#[path = "large_file.rs"]
24mod large_file;
25pub use large_cdn_config::{decode_large_cdn_config, LargeCdnRoute};
26#[path = "avatar.rs"]
27mod avatar;
28pub use avatar::*;
29#[path = "link_thumbnail.rs"]
30mod link_thumbnail;
31pub use link_thumbnail::*;
32#[path = "sticker.rs"]
33mod sticker;
34pub use sticker::*;
35
36#[derive(Clone, Copy, Serialize, Deserialize)]
37pub struct CdnLocation {
38    pub province: u32,
39    pub city: u32,
40    pub isp: u32,
41}
42#[derive(Clone, Serialize, Deserialize)]
43pub struct CdnConfig {
44    pub version: u32,
45    pub uin: u64,
46    pub client_version: u32,
47    pub client_os_type: String,
48    pub auth_key: Vec<u8>,
49    pub server_ips: Vec<Ipv4Addr>,
50    #[serde(default)]
51    pub front_id: u32,
52    #[serde(default)]
53    pub location: Option<CdnLocation>,
54}
55impl Drop for CdnConfig {
56    fn drop(&mut self) {
57        self.auth_key.zeroize();
58    }
59}
60impl fmt::Debug for CdnConfig {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.debug_struct("CdnConfig")
63            .field("credentials", &"[redacted]")
64            .finish()
65    }
66}
67impl CdnConfig {
68    pub fn validate(&self) -> Result<()> {
69        ensure!(
70            self.uin != 0
71                && !self.auth_key.is_empty()
72                && self.auth_key.len() <= 65536
73                && !self.client_os_type.is_empty()
74                && self.client_os_type.len() <= 1024
75                && !self.server_ips.is_empty()
76                && self.server_ips.len() <= 32,
77            "Invalid CDN config"
78        );
79        for ip in &self.server_ips {
80            ensure!(public_ipv4(*ip), "CDN endpoint is not public IPv4");
81        }
82        Ok(())
83    }
84}
85pub fn decode_cdn_config(input: &[u8], account: &AccountState) -> Result<CdnConfig> {
86    let f = p::decode(input)?;
87    let code = p::base_code(&f)?;
88    ensure!(code == 0, "CDN bootstrap returned {code}");
89    let group = p::decode(p::data(&f, 2)?)?;
90    let version = u32::try_from(p::scalar(&group, 1)?)?;
91    let uin = p::scalar(&group, 2)?;
92    ensure!(uin == account.uin as u64, "CDN account identity mismatch");
93    let auth_key = p::unbuffer(p::data(&group, 8)?)?;
94    let mut server_ips = vec![];
95    for field in group.iter().filter(|f| f.number == 6) {
96        let p::Value::Bytes(raw) = &field.value else {
97            bail!("Invalid CDN endpoint record");
98        };
99        let ip = p::decode(raw)?;
100        server_ips.push(std::str::from_utf8(p::data(&ip, 1)?)?.parse()?);
101    }
102    let conf = CdnConfig {
103        version,
104        uin,
105        client_version: account.device.client_version as u32,
106        client_os_type: account.device.device_type.clone(),
107        auth_key,
108        server_ips,
109        front_id: 0,
110        location: None,
111    };
112    conf.validate()?;
113    Ok(conf)
114}
115fn public_ipv4(ip: Ipv4Addr) -> bool {
116    let [a, b, c, _] = ip.octets();
117    !(a == 0
118        || a == 10
119        || a == 127
120        || a >= 224
121        || (a == 100 && (64..=127).contains(&b))
122        || (a == 169 && b == 254)
123        || (a == 172 && (16..=31).contains(&b))
124        || (a == 192 && (b == 0 || b == 168 || (b == 2 && c == 0)))
125        || (a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100)))
126        || (a == 203 && b == 0 && c == 113))
127}
128pub fn pack_cdn(fields: &CdnFields) -> Result<Vec<u8>> {
129    // A 512 MiB completion can contain 1025 ordered block etags. The byte
130    // envelope remains independently bounded, including individual values.
131    ensure!(fields.len() <= 2048, "Too many CDN fields");
132    let mut out = vec![];
133    for (k, v) in fields {
134        ensure!(
135            !k.is_empty()
136                && k.len() <= 128
137                && k.bytes()
138                    .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'),
139            "Invalid CDN key"
140        );
141        for b in [k.as_bytes(), v.as_slice()] {
142            ensure!(b.len() <= MAX_CDN_BODY, "CDN field too large");
143            out.extend((b.len() as u32).to_be_bytes());
144            out.extend(b);
145            ensure!(out.len() <= MAX_CDN_BODY, "CDN request exceeds limit");
146        }
147    }
148    Ok(out)
149}
150pub fn unpack_cdn(input: &[u8]) -> Result<CdnFields> {
151    ensure!(input.len() <= MAX_CDN_BODY, "CDN reply exceeds limit");
152    let mut c = crate::mmtls::Cursor::new(input);
153    let mut out = CdnFields::new();
154    let mut consumed = 0;
155    while consumed < input.len() {
156        ensure!(out.len() < 256, "Too many CDN reply fields");
157        let key = c.sized(4)?;
158        let value = c.sized(4)?;
159        consumed += 8 + key.len() + value.len();
160        ensure!(
161            !key.is_empty()
162                && key.len() <= 128
163                && key
164                    .iter()
165                    .all(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-'),
166            "Invalid CDN reply key"
167        );
168        let key = std::str::from_utf8(key)?.to_string();
169        ensure!(
170            out.insert(key, value.to_vec()).is_none(),
171            "Duplicate CDN reply key"
172        );
173    }
174    c.end()?;
175    Ok(out)
176}
177fn integer(fields: &CdnFields, key: &str) -> Result<i64> {
178    let raw = fields
179        .get(key)
180        .ok_or_else(|| anyhow::anyhow!("Missing CDN integer {key}"))?;
181    let text = std::str::from_utf8(raw)?;
182    ensure!(
183        !text.is_empty()
184            && text.len() <= 16
185            && text
186                .bytes()
187                .enumerate()
188                .all(|(i, b)| b.is_ascii_digit() || (i == 0 && b == b'-')),
189        "Invalid CDN integer {key}"
190    );
191    Ok(text.parse()?)
192}
193fn put(fields: &mut CdnFields, key: &str, value: impl ToString) {
194    fields.insert(key.to_string(), value.to_string().into_bytes());
195}
196fn shared(conf: &CdnConfig, sequence: u32, filetype: u32) -> CdnFields {
197    let mut f = CdnFields::new();
198    for (k, v) in [
199        ("ver", conf.version as u64),
200        ("weixinnum", conf.uin),
201        ("seq", sequence as u64),
202        ("clientversion", conf.client_version as u64),
203        ("nettype", 1),
204        ("acceptdupack", 1),
205        ("safeproto", 1),
206        ("filetype", filetype as u64),
207        ("wxchattype", 0),
208        ("lastretcode", 0),
209        ("ipseq", 0),
210        ("largesvideo", 0),
211        ("sourceflag", 0),
212    ] {
213        put(&mut f, k, v);
214    }
215    f.insert(
216        "clientostype".into(),
217        conf.client_os_type.as_bytes().to_vec(),
218    );
219    f.insert("authkey".into(), conf.auth_key.clone());
220    f
221}
222pub fn encrypt_recipient(destination: &str) -> Result<String> {
223    ensure!(
224        !destination.is_empty()
225            && destination.len() <= 512
226            && !destination
227                .chars()
228                .any(|c| c.is_control() || c.is_whitespace()),
229        "Invalid CDN recipient"
230    );
231    let key = b"wxusrname2016cdn";
232    let encrypted = openssl::symm::encrypt(
233        Cipher::aes_128_cbc(),
234        key,
235        Some(key),
236        destination.as_bytes(),
237    )?;
238    Ok(format!("@cdn2_{}", hex::encode(encrypted)))
239}
240fn ecb_encrypt(raw: &[u8], key: &[u8]) -> Result<Vec<u8>> {
241    ensure!(key.len() == 16, "Invalid CDN key");
242    Ok(openssl::symm::encrypt(
243        Cipher::aes_128_ecb(),
244        key,
245        None,
246        raw,
247    )?)
248}
249pub fn decrypt_cdn(encrypted: &[u8], key: &[u8]) -> Result<Vec<u8>> {
250    ensure!(
251        key.len() == 16
252            && !encrypted.is_empty()
253            && encrypted.len().is_multiple_of(16)
254            && encrypted.len() <= MAX_LARGE_FILE_BYTES + 16,
255        "Invalid CDN ciphertext"
256    );
257    Ok(openssl::symm::decrypt(
258        Cipher::aes_128_ecb(),
259        key,
260        None,
261        encrypted,
262    )?)
263}
264
265#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)]
266#[serde(rename_all = "snake_case")]
267pub enum MediaQuality {
268    Original,
269    Standard,
270    Thumbnail,
271    Video,
272    File,
273    LargeFile,
274}
275impl MediaQuality {
276    pub fn filetype(self) -> u32 {
277        match self {
278            Self::Original => 1,
279            Self::Standard => 2,
280            Self::Thumbnail => 3,
281            Self::Video => 4,
282            Self::File => 5,
283            Self::LargeFile => 7,
284        }
285    }
286}
287fn upload_plaintext_limit(kind: MediaQuality) -> usize {
288    match kind {
289        MediaQuality::File => MAX_CDN_TRANSFER_BYTES,
290        MediaQuality::LargeFile => MAX_LARGE_FILE_BYTES,
291        MediaQuality::Video => wechat_media::MAX_VIDEO_SOURCE_BYTES,
292        _ => MAX_NATIVE_UPLOAD_BYTES,
293    }
294}
295#[derive(Clone, Serialize, Deserialize)]
296pub struct MediaDescriptor {
297    pub file_id: String,
298    pub aes_key: Vec<u8>,
299    pub quality: MediaQuality,
300    pub expected_bytes: Option<u64>,
301    pub expected_md5: Option<String>,
302    pub strict_size: bool,
303    pub expires_at: Option<u64>,
304}
305impl Drop for MediaDescriptor {
306    fn drop(&mut self) {
307        self.aes_key.zeroize();
308        self.file_id.zeroize();
309    }
310}
311impl fmt::Debug for MediaDescriptor {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        f.debug_struct("MediaDescriptor")
314            .field("quality", &self.quality)
315            .field("metadata", &"[redacted]")
316            .finish()
317    }
318}
319pub struct DownloadedMedia {
320    pub bytes: Vec<u8>,
321    pub requested_quality: MediaQuality,
322    pub substituted_filetype: Option<u32>,
323    pub picture_format: Option<u32>,
324    pub md5: String,
325    pub sha256: String,
326    pub advertised_size_matches: Option<bool>,
327}
328#[derive(Clone, Serialize, Deserialize)]
329pub struct PreparedUpload {
330    pub kind: MediaQuality,
331    pub aes_key: Vec<u8>,
332    pub file_key: String,
333    pub encrypted: Vec<u8>,
334    pub plaintext_bytes: u32,
335    pub md5: String,
336    pub sha256: String,
337    pub file_name: String,
338    pub destination: String,
339}
340impl Drop for PreparedUpload {
341    fn drop(&mut self) {
342        self.aes_key.zeroize();
343        self.file_key.zeroize();
344        self.encrypted.zeroize();
345    }
346}
347impl fmt::Debug for PreparedUpload {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        f.debug_struct("PreparedUpload")
350            .field("kind", &self.kind)
351            .field("plaintext_bytes", &self.plaintext_bytes)
352            .field("material", &"[redacted]")
353            .finish()
354    }
355}
356impl PreparedUpload {
357    /// Generate independent upload material. Persist either this result or the
358    /// source bytes and its key/file-key before the first upload request.
359    pub fn prepare(
360        raw: &[u8],
361        kind: MediaQuality,
362        file_name: &str,
363        destination: &str,
364    ) -> Result<Self> {
365        Self::with_material(
366            raw,
367            kind,
368            file_name,
369            destination,
370            &random::<16>()?,
371            &hex::encode(random::<16>()?),
372        )
373    }
374    /// Rebuild exactly the same encrypted upload from durable owned source bytes.
375    pub fn with_material(
376        raw: &[u8],
377        kind: MediaQuality,
378        file_name: &str,
379        destination: &str,
380        key: &[u8],
381        file_key: &str,
382    ) -> Result<Self> {
383        ensure!(
384            !raw.is_empty()
385                && raw.len() <= upload_plaintext_limit(kind)
386                && [
387                    MediaQuality::File,
388                    MediaQuality::LargeFile,
389                    MediaQuality::Original,
390                    MediaQuality::Video
391                ]
392                .contains(&kind),
393            "Unsupported upload profile"
394        );
395        ensure!(
396            !file_name.is_empty()
397                && file_name.len() <= 255
398                && file_name != "."
399                && file_name != ".."
400                && !file_name.contains(['/', '\\'])
401                && !file_name.chars().any(|c| c.is_control()),
402            "Invalid upload filename"
403        );
404        ensure!(
405            key.len() == 16
406                && file_key.len() == 32
407                && file_key.bytes().all(|b| b.is_ascii_hexdigit()),
408            "Invalid durable upload material"
409        );
410        encrypt_recipient(destination)?;
411        Ok(Self {
412            kind,
413            aes_key: key.to_vec(),
414            file_key: file_key.to_string(),
415            encrypted: ecb_encrypt(raw, key)?,
416            plaintext_bytes: raw.len() as u32,
417            md5: hex::encode(md5(raw)),
418            sha256: hex::encode(sha256(raw)),
419            file_name: file_name.into(),
420            destination: destination.into(),
421        })
422    }
423    fn validate(&self) -> Result<()> {
424        ensure!(
425            self.aes_key.len() == 16
426                && self.file_key.len() == 32
427                && self.file_key.bytes().all(|b| b.is_ascii_hexdigit())
428                && self.plaintext_bytes > 0
429                && self.plaintext_bytes as usize <= upload_plaintext_limit(self.kind)
430                && self.encrypted.len() == ((self.plaintext_bytes as usize / 16) + 1) * 16,
431            "Invalid prepared upload"
432        );
433        // Validate persisted ciphertext without allocating a second whole file.
434        let mut decryptor =
435            Crypter::new(Cipher::aes_128_ecb(), Mode::Decrypt, &self.aes_key, None)?;
436        let mut buffer = zeroize::Zeroizing::new(vec![0; 128 * 1024 + 16]);
437        let mut md5 = Hasher::new(MessageDigest::md5())?;
438        let mut sha256 = Hasher::new(MessageDigest::sha256())?;
439        let mut plaintext_bytes = 0usize;
440        for chunk in self.encrypted.chunks(128 * 1024) {
441            let written = decryptor.update(chunk, &mut buffer)?;
442            md5.update(&buffer[..written])?;
443            sha256.update(&buffer[..written])?;
444            plaintext_bytes += written;
445        }
446        let written = decryptor.finalize(&mut buffer)?;
447        md5.update(&buffer[..written])?;
448        sha256.update(&buffer[..written])?;
449        plaintext_bytes += written;
450        ensure!(
451            plaintext_bytes == self.plaintext_bytes as usize
452                && hex::encode(md5.finish()?) == self.md5
453                && hex::encode(sha256.finish()?) == self.sha256,
454            "Prepared upload digest mismatch"
455        );
456        Ok(())
457    }
458}
459#[derive(Clone, Serialize, Deserialize)]
460pub struct UploadedMedia {
461    pub descriptor: MediaDescriptor,
462    pub plaintext_bytes: u32,
463    pub md5: String,
464    pub sha256: String,
465    pub file_name: String,
466    pub width: Option<u32>,
467    pub height: Option<u32>,
468}
469pub struct CdnClient {
470    config: CdnConfig,
471    timeout: Duration,
472}
473fn large_file_path(path: &str, uin: u64, sequence: u32) -> bool {
474    let parts: Vec<_> = path.split('/').collect();
475    parts.len() == 6
476        && parts[0].is_empty()
477        && parts[1] == "cdn"
478        && parts[2] == uin.to_string()
479        && sequence > 0
480        && parts[3] == sequence.to_string()
481        && matches!(parts[4], "4" | "5" | "7")
482        && matches!(parts[5], "apply" | "uploadblock" | "fin" | "complete")
483}
484impl CdnClient {
485    pub fn new(config: CdnConfig, timeout: Duration) -> Result<Self> {
486        config.validate()?;
487        ensure!(
488            !timeout.is_zero() && timeout <= Duration::from_secs(120),
489            "Invalid CDN timeout"
490        );
491        Ok(Self { config, timeout })
492    }
493    fn post(&self, path: &str, fields: &CdnFields) -> Result<CdnFields> {
494        let sequence = u32::try_from(integer(fields, "seq")?)?;
495        ensure!(sequence != 0, "Invalid CDN sequence");
496        ensure!(
497            ["/download", "/downloadbig", "/uploadv3"].contains(&path)
498                || large_file_path(path, self.config.uin, sequence),
499            "Invalid CDN path"
500        );
501        let body = pack_cdn(fields)?;
502        let client = reqwest::blocking::Client::builder()
503            .timeout(self.timeout)
504            .redirect(reqwest::redirect::Policy::none())
505            .no_proxy()
506            .build()?;
507        let mut response = client
508            .post(format!("http://{}:443{path}", self.config.server_ips[0]))
509            .header("Accept", "*/*")
510            .header("Content-Type", "application/octet-stream")
511            .header("User-Agent", "MicroMessenger Client")
512            .header("Connection", "close")
513            .header("C-Seq", sequence)
514            .body(body)
515            .send()?;
516        ensure!(
517            response.status() == 200,
518            "CDN HTTP status {}",
519            response.status().as_u16()
520        );
521        let mut raw = vec![];
522        response
523            .by_ref()
524            .take((MAX_CDN_BODY + 1) as u64)
525            .read_to_end(&mut raw)?;
526        unpack_cdn(&raw)
527    }
528    pub fn download(&self, descriptor: &MediaDescriptor) -> Result<DownloadedMedia> {
529        self.download_with_progress(descriptor, |_, _| Ok(()))
530    }
531    pub fn download_with_progress(
532        &self,
533        descriptor: &MediaDescriptor,
534        progress: impl FnMut(u64, Option<u64>) -> Result<()>,
535    ) -> Result<DownloadedMedia> {
536        self.download_with_transport(descriptor, progress, |path, fields| self.post(path, fields))
537    }
538    fn download_with_transport(
539        &self,
540        descriptor: &MediaDescriptor,
541        mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
542        mut post: impl FnMut(&str, &CdnFields) -> Result<CdnFields>,
543    ) -> Result<DownloadedMedia> {
544        ensure!(
545            !descriptor.file_id.is_empty()
546                && descriptor.file_id.len() <= 8192
547                && !descriptor.file_id.chars().any(|c| c.is_control())
548                && descriptor.aes_key.len() == 16,
549            "Invalid download descriptor"
550        );
551        if let Some(expiry) = descriptor.expires_at {
552            ensure!(expiry > crate::wire::unix_time()? as u64, "media_expired");
553        }
554        let large = descriptor.quality == MediaQuality::LargeFile;
555        let large_video = descriptor.quality == MediaQuality::Video
556            && descriptor.expected_bytes.unwrap_or(0) > MAX_CDN_TRANSFER_BYTES as u64;
557        let strict_transfer = large || large_video;
558        let plaintext_limit = if large {
559            MAX_LARGE_FILE_BYTES
560        } else if descriptor.quality == MediaQuality::Video && descriptor.expected_bytes.is_some() {
561            wechat_media::MAX_VIDEO_SOURCE_BYTES
562        } else {
563            MAX_CDN_TRANSFER_BYTES
564        };
565        let chunk_bytes = if strict_transfer { 512 * 1024 } else { 65536 };
566        let ciphertext_limit = (plaintext_limit / 16 + 1) * 16;
567        let expected_ciphertext = if strict_transfer {
568            let expected = usize::try_from(
569                descriptor
570                    .expected_bytes
571                    .ok_or_else(|| anyhow::anyhow!("Large file size is required"))?,
572            )?;
573            ensure!(
574                expected > 0 && expected <= plaintext_limit,
575                "Invalid large file size"
576            );
577            Some((expected / 16 + 1) * 16)
578        } else {
579            ensure!(
580                descriptor
581                    .expected_bytes
582                    .is_none_or(|size| size <= plaintext_limit as u64),
583                "Advertised media size outside limit"
584            );
585            None
586        };
587        if large_video {
588            ensure!(
589                descriptor.expected_md5.as_ref().is_some_and(
590                    |md5| md5.len() == 32 && md5.bytes().all(|b| b.is_ascii_hexdigit())
591                ),
592                "Large video source checksum is required"
593            );
594        }
595        let mut position = 0usize;
596        let mut total = 0usize;
597        let mut encrypted = vec![];
598        let mut substituted_filetype = None;
599        let mut picture_format = None;
600        let mut server_md5 = None;
601        for sequence in 1..=u32::try_from(ciphertext_limit.div_ceil(chunk_bytes))? {
602            progress(
603                position as u64,
604                if total == 0 { None } else { Some(total as u64) },
605            )?;
606            let mut fields = shared(&self.config, sequence, descriptor.quality.filetype());
607            let requested_end = if total == 0 {
608                chunk_bytes - 1
609            } else {
610                (total - 1).min(position + chunk_bytes - 1)
611            };
612            for (k, v) in [
613                ("wxautostart", 0),
614                ("downpicformat", 1),
615                ("rangestart", position as u64),
616                ("rangeend", requested_end as u64),
617            ] {
618                put(&mut fields, k, v);
619            }
620            fields.insert("wxmsgflag".into(), vec![]);
621            fields.insert("fileid".into(), descriptor.file_id.as_bytes().to_vec());
622            let reply = post(if large { "/downloadbig" } else { "/download" }, &fields)?;
623            let code = integer(&reply, "retcode")?;
624            ensure!(code == 0, "CDN download returned {code}");
625            if strict_transfer || reply.contains_key("seq") {
626                ensure!(
627                    integer(&reply, "seq")? == i64::from(sequence),
628                    "CDN download reply sequence mismatch"
629                );
630            }
631            if large {
632                validate_large_download_reply(&reply, descriptor, &mut server_md5)?;
633            } else if large_video {
634                validate_large_video_download_reply(&reply, descriptor, &mut server_md5)?;
635            }
636            let start = usize::try_from(integer(&reply, "rangestart")?)?;
637            let end = usize::try_from(integer(&reply, "rangeend")?)?;
638            let size = usize::try_from(integer(&reply, "totalsize")?)?;
639            let data = reply
640                .get("filedata")
641                .ok_or_else(|| anyhow::anyhow!("Missing CDN payload"))?;
642            ensure!(
643                start == position
644                    && !data.is_empty()
645                    && size > 0
646                    && size <= ciphertext_limit
647                    && expected_ciphertext.is_none_or(|expected| expected == size)
648                    && size >= position
649                    && data.len() <= size - position
650                    && end <= requested_end
651                    && (!strict_transfer || data.len() == (size - position).min(chunk_bytes))
652                    && (total == 0 || total == size)
653                    && end == start + data.len() - 1,
654                "Inconsistent CDN range response"
655            );
656            total = size;
657            if encrypted.is_empty() {
658                encrypted.try_reserve_exact(total)?;
659            }
660            position += data.len();
661            encrypted.extend(data);
662            if !large {
663                for (key, target) in [
664                    ("substituteftype", &mut substituted_filetype),
665                    ("rsppicformat", &mut picture_format),
666                ] {
667                    if reply.contains_key(key) {
668                        let value = u32::try_from(integer(&reply, key)?)?;
669                        ensure!(
670                            target.is_none_or(|previous| previous == value),
671                            "CDN representation changed during transfer"
672                        );
673                        *target = Some(value);
674                    }
675                }
676            }
677            progress(position as u64, Some(total as u64))?;
678            if position == total {
679                break;
680            }
681        }
682        ensure!(
683            total > 0 && position == total,
684            "CDN transfer did not finish within bound"
685        );
686        let bytes = decrypt_cdn(&encrypted, &descriptor.aes_key)?;
687        ensure!(bytes.len() <= plaintext_limit, "Decoded CDN body too large");
688        let size_matches = descriptor.expected_bytes.map(|n| n == bytes.len() as u64);
689        ensure!(
690            !(strict_transfer || descriptor.strict_size) || size_matches != Some(false),
691            "Advertised media size mismatch"
692        );
693        let digest = hex::encode(md5(&bytes));
694        if let Some(expected) = &descriptor.expected_md5 {
695            ensure!(
696                expected.len() == 32
697                    && expected.bytes().all(|b| b.is_ascii_hexdigit())
698                    && digest.eq_ignore_ascii_case(expected),
699                "Advertised media checksum mismatch"
700            );
701        }
702        if let Some(expected) = server_md5 {
703            ensure!(
704                digest.eq_ignore_ascii_case(&expected),
705                "CDN source checksum mismatch"
706            );
707        }
708        let sha = hex::encode(sha256(&bytes));
709        Ok(DownloadedMedia {
710            bytes,
711            requested_quality: descriptor.quality,
712            substituted_filetype,
713            picture_format,
714            md5: digest,
715            sha256: sha,
716            advertised_size_matches: size_matches,
717        })
718    }
719    pub fn upload(&self, prepared: &PreparedUpload) -> Result<UploadedMedia> {
720        self.upload_with_progress(prepared, |_, _| Ok(()))
721    }
722    pub fn upload_with_progress(
723        &self,
724        prepared: &PreparedUpload,
725        progress: impl FnMut(u64, Option<u64>) -> Result<()>,
726    ) -> Result<UploadedMedia> {
727        self.upload_with_transport(prepared, progress, |path, fields| self.post(path, fields))
728    }
729    fn upload_with_transport(
730        &self,
731        prepared: &PreparedUpload,
732        mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
733        mut post: impl FnMut(&str, &CdnFields) -> Result<CdnFields>,
734    ) -> Result<UploadedMedia> {
735        prepared.validate()?;
736        let path = legacy_upload_path(
737            prepared.kind,
738            prepared.plaintext_bytes as usize,
739            prepared.encrypted.len(),
740        )?;
741        let mut file_id = None;
742        for (sequence, chunk) in prepared.encrypted.chunks(UPLOAD_CHUNK).enumerate() {
743            let start = sequence * UPLOAD_CHUNK;
744            progress(start as u64, Some(prepared.encrypted.len() as u64))?;
745            let end = start + chunk.len() - 1;
746            let mut fields =
747                upload_fields(&self.config, prepared, sequence as u32 + 1, start, end)?;
748            fields.insert("filedata".into(), chunk.to_vec());
749            let reply = post(path, &fields)?;
750            validate_upload_reply(&reply, sequence as u32 + 1, start, end, &mut file_id)?;
751            // The final correlated acknowledgement must reach the caller so it
752            // can save completion before applying cancellation/logout fences.
753            if end + 1 < prepared.encrypted.len() {
754                progress((end + 1) as u64, Some(prepared.encrypted.len() as u64))?;
755            }
756        }
757        let file_id =
758            file_id.ok_or_else(|| anyhow::anyhow!("CDN upload returned no file identifier"))?;
759        Ok(UploadedMedia {
760            descriptor: MediaDescriptor {
761                file_id,
762                aes_key: prepared.aes_key.clone(),
763                quality: prepared.kind,
764                expected_bytes: Some(prepared.plaintext_bytes as u64),
765                expected_md5: Some(prepared.md5.clone()),
766                strict_size: true,
767                expires_at: None,
768            },
769            plaintext_bytes: prepared.plaintext_bytes,
770            md5: prepared.md5.clone(),
771            sha256: prepared.sha256.clone(),
772            file_name: prepared.file_name.clone(),
773            width: None,
774            height: None,
775        })
776    }
777}
778/// Only these fixed, nonsecret numeric controls can enter a stored diagnostic.
779#[derive(Clone, Copy, Debug, PartialEq, Eq)]
780pub enum LargeFileDownloadControl {
781    Version,
782    VideoFormat,
783}
784impl LargeFileDownloadControl {
785    pub const fn field_name(self) -> &'static str {
786        match self {
787            Self::Version => "ver",
788            Self::VideoFormat => "videoformat",
789        }
790    }
791}
792/// Strict rejection details. Unknown keys and response bytes are never retained.
793#[derive(Clone, Copy, Debug, PartialEq, Eq)]
794pub enum LargeFileDownloadError {
795    UnknownField,
796    FixedControlMismatch {
797        control: LargeFileDownloadControl,
798        actual: i64,
799    },
800}
801impl fmt::Display for LargeFileDownloadError {
802    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
803        f.write_str(match self {
804            Self::UnknownField => "Unsupported large-file download response field",
805            Self::FixedControlMismatch { .. } => "Unsupported large-file download control",
806        })
807    }
808}
809impl std::error::Error for LargeFileDownloadError {}
810
811// Type 4 retains the normal download endpoint. Unlike file7, representation
812// and routing metadata cannot be ignored for a byte-exact original video.
813fn validate_large_video_download_reply(
814    reply: &CdnFields,
815    descriptor: &MediaDescriptor,
816    server_md5: &mut Option<String>,
817) -> Result<()> {
818    const FIELDS: &[&str] = &[
819        "ver",
820        "seq",
821        "retcode",
822        "filedata",
823        "rangestart",
824        "rangeend",
825        "totalsize",
826        "retrysec",
827        "isretry",
828        "isoverload",
829        "isgetcdn",
830        "istrans",
831        "backsrcnum",
832        "rawfilesize",
833        "rawfilemd5",
834        "substituteftype",
835        "rsppicformat",
836        "videoformat",
837        "x-ClientIp",
838    ];
839    ensure!(
840        reply.keys().all(|key| FIELDS.contains(&key.as_str())),
841        "Unsupported large-video download response field"
842    );
843    ensure!(
844        integer(reply, "ver")? == 0,
845        "Unsupported large-video download version"
846    );
847    for key in [
848        "retrysec",
849        "isretry",
850        "isoverload",
851        "isgetcdn",
852        "backsrcnum",
853        "rsppicformat",
854    ] {
855        if reply.contains_key(key) {
856            ensure!(
857                integer(reply, key)? == 0,
858                "Unsupported large-video download control"
859            );
860        }
861    }
862    // Linux 4.1.13.26's normal pipeline response map discards `istrans`.
863    // Its success handler's format lookup is literal `KEY_VIDEOFORMAT`
864    // (CRC fb520bf9), not the retained wire key `videoformat` (dbe097e1).
865    // Neither affects the selected ECB/range writer. Admit only the values
866    // observed with byte-identical original-video ciphertext; retain the
867    // independent representation, source size and digest checks below.
868    for (key, observed) in [("istrans", 999), ("videoformat", 1)] {
869        if reply.contains_key(key) {
870            let value = integer(reply, key)?;
871            ensure!(
872                value == 0 || value == observed,
873                "Unsupported large-video download control"
874            );
875        }
876    }
877    if reply.contains_key("substituteftype") {
878        ensure!(
879            matches!(integer(reply, "substituteftype")?, 0 | 4),
880            "Unsupported large-video download representation"
881        );
882    }
883    if reply.contains_key("rawfilesize") {
884        ensure!(
885            u64::try_from(integer(reply, "rawfilesize")?)?
886                == descriptor.expected_bytes.unwrap_or(0),
887            "Large-video download source size mismatch"
888        );
889    }
890    if let Some(raw) = reply.get("rawfilemd5") {
891        ensure!(
892            raw.len() == 32 && raw.iter().all(u8::is_ascii_hexdigit),
893            "Invalid CDN source checksum"
894        );
895        let value = std::str::from_utf8(raw)?;
896        ensure!(
897            server_md5
898                .as_ref()
899                .is_none_or(|previous| previous.eq_ignore_ascii_case(value))
900                && descriptor
901                    .expected_md5
902                    .as_ref()
903                    .is_some_and(|expected| expected.eq_ignore_ascii_case(value)),
904            "CDN source checksum changed"
905        );
906        *server_md5 = Some(value.to_string());
907    }
908    Ok(())
909}
910
911fn validate_large_download_reply(
912    reply: &CdnFields,
913    descriptor: &MediaDescriptor,
914    server_md5: &mut Option<String>,
915) -> Result<()> {
916    const FIELDS: &[&str] = &[
917        "ver",
918        "seq",
919        "retcode",
920        "filedata",
921        "rangestart",
922        "rangeend",
923        "totalsize",
924        "retrysec",
925        "isretry",
926        "isoverload",
927        "isgetcdn",
928        "istrans",
929        "backsrcnum",
930        "rawfilesize",
931        "rawfilemd5",
932        "substituteftype",
933        "rsppicformat",
934        "videoformat",
935        "x-ClientIp",
936    ];
937    if !reply.keys().all(|key| FIELDS.contains(&key.as_str())) {
938        return Err(LargeFileDownloadError::UnknownField.into());
939    }
940    use LargeFileDownloadControl as Control;
941    for (control, expected) in [(Control::Version, 0), (Control::VideoFormat, 0)] {
942        let actual = integer(reply, control.field_name())?;
943        if actual != expected {
944            return Err(LargeFileDownloadError::FixedControlMismatch { control, actual }.into());
945        }
946    }
947    // Linux 4.1.13.26's file7 pipeline (0x8d12d60 -> 0x8ce3000 ->
948    // 0x8ca0ec0) does not consume these on a valid retcode-zero response.
949    // The first four are optional template values; the remaining four never
950    // enter its response template. They do not trigger retries or substitution.
951    for key in [
952        "retrysec",
953        "isretry",
954        "isoverload",
955        "isgetcdn",
956        "istrans",
957        "backsrcnum",
958        "substituteftype",
959        "rsppicformat",
960    ] {
961        if let Some(raw) = reply.get(key) {
962            // Keep the existing 16-byte integer parser bound, with unsigned
963            // syntax. This is a native parsing policy, not a service limit.
964            ensure!(
965                raw.iter().all(u8::is_ascii_digit),
966                "Invalid large-file metadata integer"
967            );
968            integer(reply, key)?;
969        }
970    }
971    ensure!(
972        u64::try_from(integer(reply, "rawfilesize")?)? == descriptor.expected_bytes.unwrap_or(0),
973        "Large-file download source size mismatch"
974    );
975    let raw = reply
976        .get("rawfilemd5")
977        .ok_or_else(|| anyhow::anyhow!("Missing CDN source checksum"))?;
978    ensure!(
979        raw.len() == 32 && raw.iter().all(u8::is_ascii_hexdigit),
980        "Invalid CDN source checksum"
981    );
982    let value = std::str::from_utf8(raw)?;
983    ensure!(
984        server_md5
985            .as_ref()
986            .is_none_or(|previous| previous.eq_ignore_ascii_case(value))
987            && descriptor
988                .expected_md5
989                .as_ref()
990                .is_none_or(|expected| expected.eq_ignore_ascii_case(value)),
991        "CDN source checksum changed"
992    );
993    *server_md5 = Some(value.to_string());
994    Ok(())
995}
996fn legacy_upload_path(
997    kind: MediaQuality,
998    plaintext: usize,
999    ciphertext: usize,
1000) -> Result<&'static str> {
1001    ensure!(
1002        kind != MediaQuality::LargeFile
1003            && (kind != MediaQuality::File || plaintext <= MAX_NATIVE_UPLOAD_BYTES)
1004            && ciphertext < MAX_CDN_TRANSFER_BYTES,
1005        "File requires multiphase upload"
1006    );
1007    Ok("/uploadv3")
1008}
1009fn escaped_component(s: &str) -> String {
1010    s.bytes()
1011        .map(|b| {
1012            if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
1013                (b as char).to_string()
1014            } else {
1015                format!("%{b:02X}")
1016            }
1017        })
1018        .collect()
1019}
1020fn upload_fields(
1021    conf: &CdnConfig,
1022    p: &PreparedUpload,
1023    sequence: u32,
1024    start: usize,
1025    end: usize,
1026) -> Result<CdnFields> {
1027    ensure!(
1028        sequence > 0 && start <= end && end < p.encrypted.len() && end - start < UPLOAD_CHUNK,
1029        "Invalid upload range"
1030    );
1031    let mut f = shared(conf, sequence, p.kind.filetype());
1032    for (k, v) in [
1033        ("hasthumb", 0),
1034        ("compresstype", 0),
1035        ("nocheckaeskey", 1),
1036        ("enablehit", 0),
1037        ("existancecheck", 0),
1038        ("apptype", 0),
1039        ("totalsize", p.encrypted.len() as u64),
1040        ("rawtotalsize", p.plaintext_bytes as u64),
1041        ("thumbtotalsize", 0),
1042        ("rawthumbsize", 0),
1043        ("encthumbcrc", 0),
1044        ("advideoflag", 0),
1045        ("rangestart", start as u64),
1046        ("rangeend", end as u64),
1047    ] {
1048        put(&mut f, k, v);
1049    }
1050    for (k, v) in [
1051        ("touser", encrypt_recipient(&p.destination)?),
1052        ("filekey", p.file_key.clone()),
1053        ("localname", escaped_component(&p.file_name)),
1054        ("filedatamd5", hex::encode(md5(&p.encrypted[start..=end]))),
1055        ("filemd5", p.md5.clone()),
1056        ("rawfilemd5", p.md5.clone()),
1057        ("blockmd5", hex::encode(md5(&p.encrypted[start..=end]))),
1058    ] {
1059        f.insert(k.into(), v.into_bytes());
1060    }
1061    f.insert("rawthumbmd5".into(), vec![]);
1062    Ok(f)
1063}
1064fn validate_upload_reply(
1065    reply: &CdnFields,
1066    sequence: u32,
1067    start: usize,
1068    end: usize,
1069    file_id: &mut Option<String>,
1070) -> Result<()> {
1071    let code = integer(reply, "retcode")?;
1072    ensure!(code == 0, "CDN upload returned {code}");
1073    for (key, expected) in [
1074        ("seq", sequence as u64),
1075        ("rangestart", start as u64),
1076        ("rangeend", end as u64),
1077    ] {
1078        if reply.contains_key(key) {
1079            ensure!(
1080                u64::try_from(integer(reply, key)?)? == expected,
1081                "CDN upload correlation mismatch"
1082            );
1083        }
1084    }
1085    if let Some(raw) = reply.get("fileid") {
1086        let value = std::str::from_utf8(raw)?;
1087        ensure!(
1088            !value.is_empty()
1089                && value.len() <= 4096
1090                && value
1091                    .bytes()
1092                    .all(|b| b.is_ascii_alphanumeric() || b"-_@.+/=".contains(&b)),
1093            "Invalid uploaded file identifier"
1094        );
1095        ensure!(
1096            file_id.as_deref().is_none_or(|previous| previous == value),
1097            "CDN upload file identifier changed"
1098        );
1099        *file_id = Some(value.to_string());
1100    }
1101    Ok(())
1102}
1103
1104#[derive(Clone, Serialize, Deserialize)]
1105pub struct PreparedVariant {
1106    pub encrypted: Vec<u8>,
1107    pub plaintext_bytes: u32,
1108    pub width: u32,
1109    pub height: u32,
1110    pub md5: String,
1111    pub sha256: String,
1112    pub checksum: u32,
1113    pub encrypted_checksum: u32,
1114}
1115#[derive(Clone, Serialize, Deserialize)]
1116pub struct PreparedImageUpload {
1117    pub original: PreparedUpload,
1118    pub standard: PreparedVariant,
1119    pub thumbnail: PreparedVariant,
1120    pub width: u32,
1121    pub height: u32,
1122}
1123#[derive(Clone, Serialize, Deserialize)]
1124pub struct PreparedVideoUpload {
1125    pub video: PreparedUpload,
1126    pub thumbnail: PreparedVariant,
1127}
1128#[derive(Clone, Serialize, Deserialize)]
1129pub struct UploadedImage {
1130    pub original: UploadedMedia,
1131    pub standard: UploadedMedia,
1132    pub thumbnail: UploadedMedia,
1133}
1134#[derive(Clone, Serialize, Deserialize)]
1135pub struct UploadedVideo {
1136    pub video: UploadedMedia,
1137    pub thumbnail: UploadedMedia,
1138    #[serde(default)]
1139    pub original: Option<UploadedMedia>,
1140}
1141fn jpeg_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
1142    use image::GenericImageView;
1143    ensure!(
1144        !bytes.is_empty() && bytes.len() <= 65520,
1145        "JPEG variant outside small-object profile"
1146    );
1147    ensure!(
1148        image::guess_format(bytes)? == image::ImageFormat::Jpeg,
1149        "Expected JPEG variant"
1150    );
1151    let mut reader =
1152        image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg);
1153    let mut limits = image::Limits::default();
1154    limits.max_image_width = Some(4096);
1155    limits.max_image_height = Some(4096);
1156    limits.max_alloc = Some(64 * 1024 * 1024);
1157    reader.limits(limits);
1158    let image = reader.decode()?;
1159    let (w, h) = image.dimensions();
1160    ensure!(
1161        w > 0 && h > 0 && w <= 4096 && h <= 4096,
1162        "JPEG dimensions outside limit"
1163    );
1164    Ok((w, h))
1165}
1166pub fn adler32_zero(bytes: &[u8]) -> u32 {
1167    let mut a = 0u32;
1168    let mut b = 0u32;
1169    for &v in bytes {
1170        a = (a + v as u32) % 65521;
1171        b = (b + a) % 65521;
1172    }
1173    (b << 16) | a
1174}
1175impl PreparedVariant {
1176    fn new(raw: &[u8], key: &[u8]) -> Result<Self> {
1177        let (width, height) = jpeg_dimensions(raw)?;
1178        let encrypted = ecb_encrypt(raw, key)?;
1179        Ok(Self {
1180            checksum: adler32_zero(raw),
1181            encrypted_checksum: adler32_zero(&encrypted),
1182            encrypted,
1183            plaintext_bytes: raw.len() as u32,
1184            width,
1185            height,
1186            md5: hex::encode(md5(raw)),
1187            sha256: hex::encode(sha256(raw)),
1188        })
1189    }
1190    fn validate(&self, key: &[u8]) -> Result<()> {
1191        let raw = decrypt_cdn(&self.encrypted, key)?;
1192        let (w, h) = jpeg_dimensions(&raw)?;
1193        ensure!(
1194            raw.len() == self.plaintext_bytes as usize
1195                && w == self.width
1196                && h == self.height
1197                && hex::encode(md5(&raw)) == self.md5
1198                && hex::encode(sha256(&raw)) == self.sha256
1199                && adler32_zero(&raw) == self.checksum
1200                && adler32_zero(&self.encrypted) == self.encrypted_checksum,
1201            "Prepared JPEG variant mismatch"
1202        );
1203        Ok(())
1204    }
1205}
1206impl PreparedImageUpload {
1207    pub fn with_material(
1208        original: &[u8],
1209        standard: &[u8],
1210        thumbnail: &[u8],
1211        file_name: &str,
1212        destination: &str,
1213        key: &[u8],
1214        file_key: &str,
1215    ) -> Result<Self> {
1216        let (width, height) = jpeg_dimensions(original)?;
1217        let original = PreparedUpload::with_material(
1218            original,
1219            MediaQuality::Original,
1220            file_name,
1221            destination,
1222            key,
1223            file_key,
1224        )?;
1225        let standard = PreparedVariant::new(standard, key)?;
1226        let thumbnail = PreparedVariant::new(thumbnail, key)?;
1227        let p = Self {
1228            original,
1229            standard,
1230            thumbnail,
1231            width,
1232            height,
1233        };
1234        p.validate()?;
1235        Ok(p)
1236    }
1237    /// Supplied variants must be independent real JPEGs. This function decodes
1238    /// them fully but does not resize or invent an unsupplied representation.
1239    pub fn prepare(
1240        original: &[u8],
1241        standard: &[u8],
1242        thumbnail: &[u8],
1243        file_name: &str,
1244        destination: &str,
1245    ) -> Result<Self> {
1246        let (width, height) = jpeg_dimensions(original)?;
1247        let original =
1248            PreparedUpload::prepare(original, MediaQuality::Original, file_name, destination)?;
1249        let standard = PreparedVariant::new(standard, &original.aes_key)?;
1250        let thumbnail = PreparedVariant::new(thumbnail, &original.aes_key)?;
1251        let p = Self {
1252            original,
1253            standard,
1254            thumbnail,
1255            width,
1256            height,
1257        };
1258        p.validate()?;
1259        Ok(p)
1260    }
1261    fn validate(&self) -> Result<()> {
1262        self.original.validate()?;
1263        let raw = decrypt_cdn(&self.original.encrypted, &self.original.aes_key)?;
1264        ensure!(
1265            jpeg_dimensions(&raw)? == (self.width, self.height),
1266            "Prepared original dimensions differ"
1267        );
1268        self.standard.validate(&self.original.aes_key)?;
1269        self.thumbnail.validate(&self.original.aes_key)?;
1270        ensure!(
1271            self.original.plaintext_bytes <= 65520
1272                && self.standard.width <= self.width
1273                && self.standard.height <= self.height
1274                && self.thumbnail.width <= self.standard.width
1275                && self.thumbnail.height <= self.standard.height
1276                && self.thumbnail.width <= 320
1277                && self.thumbnail.height <= 320,
1278            "Invalid JPEG variant sizes"
1279        );
1280        Ok(())
1281    }
1282}
1283impl PreparedVideoUpload {
1284    pub fn with_material(
1285        video: &[u8],
1286        thumbnail: &[u8],
1287        file_name: &str,
1288        destination: &str,
1289        key: &[u8],
1290        file_key: &str,
1291    ) -> Result<Self> {
1292        ensure!(
1293            video.len() >= 12 && video.len() <= MAX_NATIVE_UPLOAD_BYTES && video[4..8] == *b"ftyp",
1294            "Expected bounded MP4 container"
1295        );
1296        let video = PreparedUpload::with_material(
1297            video,
1298            MediaQuality::Video,
1299            file_name,
1300            destination,
1301            key,
1302            file_key,
1303        )?;
1304        let thumbnail = PreparedVariant::new(thumbnail, key)?;
1305        ensure!(
1306            thumbnail.width <= 320 && thumbnail.height <= 320,
1307            "Video thumbnail outside limit"
1308        );
1309        Ok(Self { video, thumbnail })
1310    }
1311    pub fn prepare(
1312        video: &[u8],
1313        thumbnail: &[u8],
1314        file_name: &str,
1315        destination: &str,
1316    ) -> Result<Self> {
1317        ensure!(
1318            video.len() >= 12 && video.len() <= MAX_NATIVE_UPLOAD_BYTES && video[4..8] == *b"ftyp",
1319            "Expected bounded MP4 container"
1320        );
1321        let video = PreparedUpload::prepare(video, MediaQuality::Video, file_name, destination)?;
1322        let thumbnail = PreparedVariant::new(thumbnail, &video.aes_key)?;
1323        ensure!(
1324            thumbnail.width <= 320 && thumbnail.height <= 320,
1325            "Video thumbnail outside limit"
1326        );
1327        Ok(Self { video, thumbnail })
1328    }
1329}
1330fn add_thumbnail(fields: &mut CdnFields, v: &PreparedVariant) {
1331    for (k, n) in [
1332        ("hasthumb", 1),
1333        ("thumbfiletype", 3),
1334        ("thumbtotalsize", v.encrypted.len() as u64),
1335        ("rawthumbsize", v.plaintext_bytes as u64),
1336        ("thumbcrc", v.checksum as u64),
1337        ("encthumbcrc", v.encrypted_checksum as u64),
1338    ] {
1339        put(fields, k, n);
1340    }
1341    fields.insert("rawthumbmd5".into(), v.md5.as_bytes().to_vec());
1342    fields.insert("thumbdata".into(), v.encrypted.clone());
1343}
1344fn uploaded_primary(
1345    prepared: &PreparedUpload,
1346    file_id: &str,
1347    width: Option<u32>,
1348    height: Option<u32>,
1349) -> UploadedMedia {
1350    UploadedMedia {
1351        descriptor: MediaDescriptor {
1352            file_id: file_id.to_string(),
1353            aes_key: prepared.aes_key.clone(),
1354            quality: prepared.kind,
1355            expected_bytes: Some(prepared.plaintext_bytes as u64),
1356            expected_md5: Some(prepared.md5.clone()),
1357            strict_size: true,
1358            expires_at: None,
1359        },
1360        plaintext_bytes: prepared.plaintext_bytes,
1361        md5: prepared.md5.clone(),
1362        sha256: prepared.sha256.clone(),
1363        file_name: prepared.file_name.clone(),
1364        width,
1365        height,
1366    }
1367}
1368fn uploaded_variant(
1369    primary: &PreparedUpload,
1370    v: &PreparedVariant,
1371    file_id: &str,
1372    quality: MediaQuality,
1373) -> UploadedMedia {
1374    UploadedMedia {
1375        descriptor: MediaDescriptor {
1376            file_id: file_id.to_string(),
1377            aes_key: primary.aes_key.clone(),
1378            quality,
1379            expected_bytes: Some(v.plaintext_bytes as u64),
1380            expected_md5: Some(v.md5.clone()),
1381            strict_size: true,
1382            expires_at: None,
1383        },
1384        plaintext_bytes: v.plaintext_bytes,
1385        md5: v.md5.clone(),
1386        sha256: v.sha256.clone(),
1387        file_name: primary.file_name.clone(),
1388        width: Some(v.width),
1389        height: Some(v.height),
1390    }
1391}
1392impl CdnClient {
1393    pub fn upload_image(&self, p: &PreparedImageUpload) -> Result<UploadedImage> {
1394        p.validate()?;
1395        let end = p.original.encrypted.len() - 1;
1396        let mut f = upload_fields(&self.config, &p.original, 1, 0, end)?;
1397        f.insert("filedata".into(), p.original.encrypted.clone());
1398        add_thumbnail(&mut f, &p.thumbnail);
1399        for (k, v) in [
1400            ("imgheight", p.height as u64),
1401            ("imgwidth", p.width as u64),
1402            ("midimgrawsize", p.standard.plaintext_bytes as u64),
1403            ("midimgtotalsize", p.standard.encrypted.len() as u64),
1404            ("midimgchecksum", p.standard.encrypted_checksum as u64),
1405            ("file_format", 0),
1406            ("mid_format", 0),
1407        ] {
1408            put(&mut f, k, v);
1409        }
1410        f.insert("midimgmd5".into(), p.standard.md5.as_bytes().to_vec());
1411        f.insert("midimgdata".into(), p.standard.encrypted.clone());
1412        let reply = self.post("/uploadv3", &f)?;
1413        let mut file_id = None;
1414        validate_upload_reply(&reply, 1, 0, end, &mut file_id)?;
1415        let id =
1416            file_id.ok_or_else(|| anyhow::anyhow!("Image upload returned no file identifier"))?;
1417        Ok(UploadedImage {
1418            original: uploaded_primary(&p.original, &id, Some(p.width), Some(p.height)),
1419            standard: uploaded_variant(&p.original, &p.standard, &id, MediaQuality::Standard),
1420            thumbnail: uploaded_variant(&p.original, &p.thumbnail, &id, MediaQuality::Thumbnail),
1421        })
1422    }
1423    pub fn upload_video(&self, p: &PreparedVideoUpload) -> Result<UploadedVideo> {
1424        self.upload_video_with_progress(p, |_, _| Ok(()))
1425    }
1426    pub fn upload_video_with_progress(
1427        &self,
1428        p: &PreparedVideoUpload,
1429        progress: impl FnMut(u64, Option<u64>) -> Result<()>,
1430    ) -> Result<UploadedVideo> {
1431        self.upload_video_with_transport(p, progress, |path, fields| self.post(path, fields))
1432    }
1433    fn upload_video_with_transport(
1434        &self,
1435        p: &PreparedVideoUpload,
1436        mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
1437        mut post: impl FnMut(&str, &CdnFields) -> Result<CdnFields>,
1438    ) -> Result<UploadedVideo> {
1439        p.video.validate()?;
1440        ensure!(
1441            p.video.kind == MediaQuality::Video
1442                && p.video.plaintext_bytes as usize <= MAX_NATIVE_UPLOAD_BYTES,
1443            "Invalid normal video upload profile"
1444        );
1445        p.thumbnail.validate(&p.video.aes_key)?;
1446        ensure!(
1447            p.thumbnail.width <= 320 && p.thumbnail.height <= 320,
1448            "Video thumbnail outside limit"
1449        );
1450        let mut file_id = None;
1451        for (sequence, chunk) in p.video.encrypted.chunks(UPLOAD_CHUNK).enumerate() {
1452            let start = sequence * UPLOAD_CHUNK;
1453            let end = start + chunk.len() - 1;
1454            progress(start as u64, Some(p.video.encrypted.len() as u64))?;
1455            let mut fields =
1456                upload_fields(&self.config, &p.video, sequence as u32 + 1, start, end)?;
1457            fields.insert("filedata".into(), chunk.to_vec());
1458            if start == 0 {
1459                add_thumbnail(&mut fields, &p.thumbnail);
1460            }
1461            put(&mut fields, "smallvideoflag", 0);
1462            let reply = post("/uploadv3", &fields)?;
1463            validate_upload_reply(&reply, sequence as u32 + 1, start, end, &mut file_id)?;
1464            if end + 1 < p.video.encrypted.len() {
1465                progress((end + 1) as u64, Some(p.video.encrypted.len() as u64))?;
1466            }
1467        }
1468        let id =
1469            file_id.ok_or_else(|| anyhow::anyhow!("Video upload returned no file identifier"))?;
1470        Ok(UploadedVideo {
1471            video: uploaded_primary(&p.video, &id, None, None),
1472            thumbnail: uploaded_variant(&p.video, &p.thumbnail, &id, MediaQuality::Thumbnail),
1473            original: None,
1474        })
1475    }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481    fn client() -> CdnClient {
1482        CdnClient::new(
1483            CdnConfig {
1484                version: 1,
1485                uin: 12345,
1486                client_version: 456,
1487                client_os_type: "synthetic-client".into(),
1488                auth_key: b"synthetic-auth".to_vec(),
1489                server_ips: vec![Ipv4Addr::new(1, 1, 1, 1)],
1490                front_id: 0,
1491                location: None,
1492            },
1493            Duration::from_secs(25),
1494        )
1495        .unwrap()
1496    }
1497    fn download_fixture(size: usize, quality: MediaQuality) -> (MediaDescriptor, Vec<u8>, Vec<u8>) {
1498        let raw = vec![0x5a; size];
1499        let key = vec![0x31; 16];
1500        let ciphertext = ecb_encrypt(&raw, &key).unwrap();
1501        let descriptor = MediaDescriptor {
1502            file_id: "synthetic-object".into(),
1503            aes_key: key,
1504            quality,
1505            expected_bytes: Some(size as u64),
1506            expected_md5: Some(hex::encode(md5(&raw))),
1507            strict_size: true,
1508            expires_at: None,
1509        };
1510        (descriptor, raw, ciphertext)
1511    }
1512    fn download_reply(
1513        fields: &CdnFields,
1514        descriptor: &MediaDescriptor,
1515        ciphertext: &[u8],
1516    ) -> CdnFields {
1517        let mut reply = CdnFields::new();
1518        let start = integer(fields, "rangestart").unwrap() as usize;
1519        let end = (integer(fields, "rangeend").unwrap() as usize).min(ciphertext.len() - 1);
1520        assert_eq!(fields["fileid"], descriptor.file_id.as_bytes());
1521        assert_eq!(integer(fields, "weixinnum").unwrap(), 12345);
1522        assert!(!fields.contains_key("aeskey"));
1523        for (key, value) in [
1524            ("ver", 0),
1525            ("seq", integer(fields, "seq").unwrap()),
1526            ("retcode", 0),
1527            ("rangestart", start as i64),
1528            ("rangeend", end as i64),
1529            ("totalsize", ciphertext.len() as i64),
1530        ] {
1531            put(&mut reply, key, value);
1532        }
1533        reply.insert("filedata".into(), ciphertext[start..=end].to_vec());
1534        if descriptor.quality == MediaQuality::LargeFile {
1535            for (key, value) in [
1536                ("retrysec", 0),
1537                ("isretry", 0),
1538                ("isoverload", 0),
1539                ("isgetcdn", 0),
1540                ("istrans", 999),
1541                ("backsrcnum", 0),
1542                ("substituteftype", 7),
1543                ("rsppicformat", 0),
1544                ("videoformat", 0),
1545                ("rawfilesize", descriptor.expected_bytes.unwrap() as i64),
1546            ] {
1547                put(&mut reply, key, value);
1548            }
1549            reply.insert(
1550                "rawfilemd5".into(),
1551                descriptor
1552                    .expected_md5
1553                    .as_ref()
1554                    .unwrap()
1555                    .as_bytes()
1556                    .to_vec(),
1557            );
1558        }
1559        reply
1560    }
1561    #[test]
1562    fn large_video_keeps_normal_endpoint_type_and_exact_source_bytes() {
1563        let (descriptor, raw, ciphertext) =
1564            download_fixture(MAX_CDN_TRANSFER_BYTES + 17, MediaQuality::Video);
1565        let mut requests = 0;
1566        let downloaded = client()
1567            .download_with_transport(
1568                &descriptor,
1569                |_, _| Ok(()),
1570                |path, fields| {
1571                    requests += 1;
1572                    assert_eq!(path, "/download");
1573                    assert_eq!(integer(fields, "filetype")?, 4);
1574                    assert_eq!(integer(fields, "seq")?, requests);
1575                    assert_eq!(integer(fields, "rangestart")?, (requests - 1) * 512 * 1024);
1576                    let mut reply = download_reply(fields, &descriptor, &ciphertext);
1577                    put(&mut reply, "substituteftype", 4);
1578                    put(&mut reply, "videoformat", 1);
1579                    put(&mut reply, "istrans", 999);
1580                    Ok(reply)
1581                },
1582            )
1583            .unwrap();
1584        assert_eq!(requests, 51);
1585        assert_eq!(downloaded.bytes, raw);
1586        assert_eq!(downloaded.md5, descriptor.expected_md5.as_deref().unwrap());
1587        assert_eq!(downloaded.sha256, hex::encode(sha256(&raw)));
1588        assert_eq!(downloaded.substituted_filetype, Some(4));
1589    }
1590
1591    #[test]
1592    fn large_video_rejects_unknown_controls_substitution_and_unbound_source() {
1593        let (mut descriptor, _, ciphertext) = download_fixture(16, MediaQuality::Video);
1594        descriptor.expected_bytes = Some(MAX_CDN_TRANSFER_BYTES as u64 + 1);
1595        let fields = BTreeMap::from([
1596            ("rangestart".into(), b"0".to_vec()),
1597            ("rangeend".into(), b"31".to_vec()),
1598            ("seq".into(), b"1".to_vec()),
1599            ("weixinnum".into(), b"12345".to_vec()),
1600            ("fileid".into(), descriptor.file_id.as_bytes().to_vec()),
1601        ]);
1602        let valid = download_reply(&fields, &descriptor, &ciphertext);
1603        for (key, value) in [
1604            ("unknown", "private"),
1605            ("ver", "1"),
1606            ("videoformat", "2"),
1607            ("istrans", "1"),
1608            ("KEY_VIDEOFORMAT", "1"),
1609            ("isretry", "1"),
1610            ("isgetcdn", "1"),
1611            ("rsppicformat", "3"),
1612            ("substituteftype", "7"),
1613            ("rawfilesize", "1"),
1614            ("rawfilemd5", "00000000000000000000000000000000"),
1615        ] {
1616            let mut reply = valid.clone();
1617            reply.insert(key.into(), value.as_bytes().to_vec());
1618            let error = validate_large_video_download_reply(&reply, &descriptor, &mut None)
1619                .unwrap_err()
1620                .to_string();
1621            assert!(!error.contains("private"));
1622        }
1623        for (size, checksum) in [
1624            (
1625                Some(wechat_media::MAX_VIDEO_SOURCE_BYTES as u64 + 1),
1626                descriptor.expected_md5.clone(),
1627            ),
1628            (descriptor.expected_bytes, None),
1629        ] {
1630            descriptor.expected_bytes = size;
1631            descriptor.expected_md5 = checksum;
1632            let mut requests = 0;
1633            assert!(client()
1634                .download_with_transport(
1635                    &descriptor,
1636                    |_, _| Ok(()),
1637                    |_, _| {
1638                        requests += 1;
1639                        bail!("must not request")
1640                    }
1641                )
1642                .is_err());
1643            assert_eq!(requests, 0);
1644        }
1645    }
1646
1647    #[test]
1648    fn cold_large_descriptor_downloads_with_padding_and_hashes() {
1649        let (descriptor, raw, ciphertext) =
1650            download_fixture(32 * 1024 * 1024, MediaQuality::LargeFile);
1651        assert_eq!(ciphertext.len(), 32 * 1024 * 1024 + 16);
1652        let stored = serde_json::to_vec(&descriptor).unwrap();
1653        let cold: MediaDescriptor = serde_json::from_slice(&stored).unwrap();
1654        let events = std::cell::RefCell::new(Vec::new());
1655        let mut requests = 0;
1656        let mut previous = 0;
1657        let downloaded = client()
1658            .download_with_transport(
1659                &cold,
1660                |done, total| {
1661                    events.borrow_mut().push("progress");
1662                    assert!(done >= previous);
1663                    previous = done;
1664                    if let Some(total) = total {
1665                        assert_eq!(total, ciphertext.len() as u64);
1666                    }
1667                    Ok(())
1668                },
1669                |path, fields| {
1670                    events.borrow_mut().push("request");
1671                    requests += 1;
1672                    assert_eq!(path, "/downloadbig");
1673                    assert_eq!(integer(fields, "filetype")?, 7);
1674                    assert_eq!(integer(fields, "seq")?, requests);
1675                    assert_eq!(integer(fields, "rangestart")?, (requests - 1) * 512 * 1024);
1676                    Ok(download_reply(fields, &cold, &ciphertext))
1677                },
1678            )
1679            .unwrap();
1680        assert_eq!(requests, 65);
1681        assert_eq!(previous, ciphertext.len() as u64);
1682        for chunk in events.borrow().chunks(3) {
1683            assert_eq!(chunk, ["progress", "request", "progress"]);
1684        }
1685        assert_eq!(downloaded.bytes, raw);
1686        assert_eq!(
1687            downloaded.md5,
1688            descriptor.expected_md5.as_ref().unwrap().as_str()
1689        );
1690        assert_eq!(downloaded.sha256, hex::encode(sha256(&raw)));
1691        assert_eq!(downloaded.requested_quality, MediaQuality::LargeFile);
1692        assert_eq!(downloaded.substituted_filetype, None);
1693        assert_eq!(downloaded.picture_format, None);
1694        assert_eq!(downloaded.advertised_size_matches, Some(true));
1695    }
1696    #[test]
1697    fn large_download_rejections_expose_only_typed_static_control_details() {
1698        use LargeFileDownloadControl as Control;
1699        let (descriptor, _, ciphertext) =
1700            download_fixture(512 * 1024 + 17, MediaQuality::LargeFile);
1701        for (control, actual) in [(Control::Version, 1), (Control::VideoFormat, 1)] {
1702            let mut calls = 0;
1703            let error = client()
1704                .download_with_transport(
1705                    &descriptor,
1706                    |_, _| Ok(()),
1707                    |_, fields| {
1708                        calls += 1;
1709                        let mut reply = download_reply(fields, &descriptor, &ciphertext);
1710                        put(&mut reply, control.field_name(), actual);
1711                        Ok(reply)
1712                    },
1713                )
1714                .err()
1715                .unwrap();
1716            assert_eq!(calls, 1);
1717            assert_eq!(
1718                error.downcast_ref::<LargeFileDownloadError>(),
1719                Some(&LargeFileDownloadError::FixedControlMismatch { control, actual })
1720            );
1721            assert_eq!(error.to_string(), "Unsupported large-file download control");
1722        }
1723        let secret = "private-response-token-and-file-id";
1724        let error = client()
1725            .download_with_transport(
1726                &descriptor,
1727                |_, _| Ok(()),
1728                |_, fields| {
1729                    let mut reply = download_reply(fields, &descriptor, &ciphertext);
1730                    reply.insert(secret.into(), secret.as_bytes().to_vec());
1731                    Ok(reply)
1732                },
1733            )
1734            .err()
1735            .unwrap();
1736        assert_eq!(
1737            error.downcast_ref::<LargeFileDownloadError>(),
1738            Some(&LargeFileDownloadError::UnknownField)
1739        );
1740        assert_eq!(
1741            error.to_string(),
1742            "Unsupported large-file download response field"
1743        );
1744        assert!(!format!("{error:?}").contains(secret));
1745    }
1746    #[test]
1747    fn large_download_rejects_correlation_format_unknown_fields_and_ranges() {
1748        let (descriptor, _, ciphertext) =
1749            download_fixture(512 * 1024 + 17, MediaQuality::LargeFile);
1750        for (key, value) in [
1751            ("seq", 2),
1752            ("ver", 1),
1753            ("retcode", -1),
1754            ("videoformat", 1),
1755            ("rangestart", 1),
1756            ("rangeend", 512 * 1024),
1757            ("totalsize", ciphertext.len() as i64 + 16),
1758            ("rawfilesize", 1),
1759            ("futureauth", 1),
1760        ] {
1761            let mut requests = 0;
1762            let outcome = client().download_with_transport(
1763                &descriptor,
1764                |_, _| Ok(()),
1765                |_, fields| {
1766                    requests += 1;
1767                    let mut reply = download_reply(fields, &descriptor, &ciphertext);
1768                    put(&mut reply, key, value);
1769                    Ok(reply)
1770                },
1771            );
1772            assert!(outcome.is_err(), "Accepted invalid {key}");
1773            assert_eq!(requests, 1);
1774        }
1775        for missing in ["seq", "ver", "videoformat", "rawfilemd5", "filedata"] {
1776            assert!(
1777                client()
1778                    .download_with_transport(
1779                        &descriptor,
1780                        |_, _| Ok(()),
1781                        |_, fields| {
1782                            let mut reply = download_reply(fields, &descriptor, &ciphertext);
1783                            reply.remove(missing);
1784                            Ok(reply)
1785                        }
1786                    )
1787                    .is_err(),
1788                "Accepted missing {missing}"
1789            );
1790        }
1791    }
1792    #[test]
1793    fn file7_success_metadata_is_optional_and_does_not_change_verified_bytes() {
1794        // These eight names come from the reference template/consumer join,
1795        // independently of the native validator's allowlist.
1796        let metadata = [
1797            "isretry",
1798            "retrysec",
1799            "isoverload",
1800            "isgetcdn",
1801            "istrans",
1802            "backsrcnum",
1803            "substituteftype",
1804            "rsppicformat",
1805        ];
1806        let (descriptor, raw, ciphertext) = download_fixture(1024 * 1024, MediaQuality::LargeFile);
1807        assert_eq!(ciphertext.len(), 1024 * 1024 + 16);
1808        // Each field may be absent or vary independently on the first, middle,
1809        // and final padding-only ranges, without creating another request.
1810        for selected in metadata.into_iter().map(Some).chain([None]) {
1811            for omit in [false, true] {
1812                let mut requests = 0;
1813                let result = client()
1814                    .download_with_transport(
1815                        &descriptor,
1816                        |_, _| Ok(()),
1817                        |path, fields| {
1818                            requests += 1;
1819                            assert_eq!(path, "/downloadbig");
1820                            assert_eq!(integer(fields, "seq")?, requests);
1821                            let mut reply = download_reply(fields, &descriptor, &ciphertext);
1822                            for key in metadata {
1823                                if selected.is_none_or(|selected| selected == key) {
1824                                    if omit {
1825                                        reply.remove(key);
1826                                    } else {
1827                                        put(
1828                                            &mut reply,
1829                                            key,
1830                                            match requests {
1831                                                1 => 1,
1832                                                2 => 999,
1833                                                3 => 9_999_999_999_999_999i64,
1834                                                _ => panic!("Unexpected retry or extra range"),
1835                                            },
1836                                        );
1837                                    }
1838                                }
1839                            }
1840                            Ok(reply)
1841                        },
1842                    )
1843                    .unwrap();
1844                assert_eq!(requests, 3);
1845                assert_eq!(result.bytes, raw);
1846                assert_eq!(result.md5, descriptor.expected_md5.as_deref().unwrap());
1847                assert_eq!(result.sha256, hex::encode(sha256(&raw)));
1848                assert_eq!(result.requested_quality, MediaQuality::LargeFile);
1849                assert_eq!(result.substituted_filetype, None);
1850                assert_eq!(result.picture_format, None);
1851            }
1852        }
1853    }
1854    #[test]
1855    fn file7_metadata_parser_bounds_and_errors_remain_strict() {
1856        let (descriptor, _, ciphertext) = download_fixture(512 * 1024, MediaQuality::LargeFile);
1857        for key in [
1858            "isretry",
1859            "retrysec",
1860            "isoverload",
1861            "isgetcdn",
1862            "istrans",
1863            "backsrcnum",
1864            "substituteftype",
1865            "rsppicformat",
1866        ] {
1867            for malformed in [
1868                b"".as_slice(),
1869                b"-1",
1870                b"-0",
1871                b"+1",
1872                b" 1",
1873                b"1x",
1874                b"99999999999999999",
1875                b"\xff",
1876            ] {
1877                let mut requests = 0;
1878                assert!(
1879                    client()
1880                        .download_with_transport(
1881                            &descriptor,
1882                            |_, _| Ok(()),
1883                            |_, fields| {
1884                                requests += 1;
1885                                let mut reply = download_reply(fields, &descriptor, &ciphertext);
1886                                reply.insert(key.into(), malformed.to_vec());
1887                                Ok(reply)
1888                            },
1889                        )
1890                        .is_err(),
1891                    "Accepted malformed {key}"
1892                );
1893                assert_eq!(requests, 1);
1894            }
1895        }
1896        let mut requests = 0;
1897        let error = client()
1898            .download_with_transport(
1899                &descriptor,
1900                |_, _| Ok(()),
1901                |_, fields| {
1902                    requests += 1;
1903                    let mut reply = download_reply(fields, &descriptor, &ciphertext);
1904                    for key in ["isretry", "retrysec", "isoverload", "isgetcdn"] {
1905                        put(&mut reply, key, 1);
1906                    }
1907                    put(&mut reply, "retcode", -1);
1908                    Ok(reply)
1909                },
1910            )
1911            .err()
1912            .unwrap();
1913        assert_eq!(error.to_string(), "CDN download returned -1");
1914        assert_eq!(requests, 1);
1915    }
1916    #[test]
1917    fn normal_download_keeps_representation_metadata_and_consistency_checks() {
1918        let (descriptor, raw, ciphertext) = download_fixture(65536, MediaQuality::File);
1919        let result = client()
1920            .download_with_transport(
1921                &descriptor,
1922                |_, _| Ok(()),
1923                |path, fields| {
1924                    assert_eq!(path, "/download");
1925                    let mut reply = download_reply(fields, &descriptor, &ciphertext);
1926                    put(&mut reply, "substituteftype", 5);
1927                    put(&mut reply, "rsppicformat", 3);
1928                    Ok(reply)
1929                },
1930            )
1931            .unwrap();
1932        assert_eq!(result.bytes, raw);
1933        assert_eq!(result.substituted_filetype, Some(5));
1934        assert_eq!(result.picture_format, Some(3));
1935        let mut requests = 0;
1936        assert!(client()
1937            .download_with_transport(
1938                &descriptor,
1939                |_, _| Ok(()),
1940                |_, fields| {
1941                    requests += 1;
1942                    let mut reply = download_reply(fields, &descriptor, &ciphertext);
1943                    put(&mut reply, "rsppicformat", requests);
1944                    Ok(reply)
1945                },
1946            )
1947            .is_err());
1948        assert_eq!(requests, 2);
1949    }
1950    #[test]
1951    fn download_refuses_truncated_overlong_and_changing_source_bytes() {
1952        let (descriptor, _, ciphertext) =
1953            download_fixture(512 * 1024 + 17, MediaQuality::LargeFile);
1954        for failure in [
1955            "empty",
1956            "overlong",
1957            "source-checksum",
1958            "changed-checksum",
1959            "changed-total",
1960            "short-nonfinal",
1961        ] {
1962            let mut requests = 0;
1963            let outcome = client().download_with_transport(
1964                &descriptor,
1965                |_, _| Ok(()),
1966                |_, fields| {
1967                    requests += 1;
1968                    let mut reply = download_reply(fields, &descriptor, &ciphertext);
1969                    match failure {
1970                        "empty" => {
1971                            reply.insert("filedata".into(), vec![]);
1972                        }
1973                        "overlong" => {
1974                            reply.get_mut("filedata").unwrap().push(1);
1975                            put(&mut reply, "rangeend", 512 * 1024);
1976                        }
1977                        "source-checksum" => {
1978                            reply.insert(
1979                                "rawfilemd5".into(),
1980                                b"0123456789abcdef0123456789abcdef".to_vec(),
1981                            );
1982                        }
1983                        "changed-checksum" if requests == 2 => {
1984                            reply.insert(
1985                                "rawfilemd5".into(),
1986                                b"0123456789abcdef0123456789abcdef".to_vec(),
1987                            );
1988                        }
1989                        "changed-total" if requests == 2 => {
1990                            put(&mut reply, "totalsize", ciphertext.len() + 16)
1991                        }
1992                        "short-nonfinal" => {
1993                            reply.insert("filedata".into(), vec![1]);
1994                            put(&mut reply, "rangeend", integer(fields, "rangestart")?);
1995                        }
1996                        _ => {}
1997                    }
1998                    Ok(reply)
1999                },
2000            );
2001            assert!(outcome.is_err(), "Accepted {failure}");
2002            assert!(requests <= 2);
2003        }
2004    }
2005    #[test]
2006    fn large_download_checks_padding_actual_size_and_digest_independently() {
2007        let (descriptor, _, ciphertext) =
2008            download_fixture(512 * 1024 + 17, MediaQuality::LargeFile);
2009        let mut invalid_padding = ciphertext.clone();
2010        let mut encoder = openssl::symm::Crypter::new(
2011            Cipher::aes_128_ecb(),
2012            openssl::symm::Mode::Encrypt,
2013            &descriptor.aes_key,
2014            None,
2015        )
2016        .unwrap();
2017        encoder.pad(false);
2018        let mut block = [0u8; 32];
2019        let written = encoder.update(&[0u8; 16], &mut block).unwrap();
2020        assert_eq!(
2021            written + encoder.finalize(&mut block[written..]).unwrap(),
2022            16
2023        );
2024        let last = invalid_padding.len() - 16;
2025        invalid_padding[last..].copy_from_slice(&block[..16]);
2026        assert!(client()
2027            .download_with_transport(
2028                &descriptor,
2029                |_, _| Ok(()),
2030                |_, fields| Ok(download_reply(fields, &descriptor, &invalid_padding))
2031            )
2032            .is_err());
2033        let mut without_advertised_md5 = descriptor.clone();
2034        without_advertised_md5.expected_md5 = None;
2035        for change_later in [false, true] {
2036            let mut requests = 0;
2037            assert!(client()
2038                .download_with_transport(
2039                    &without_advertised_md5,
2040                    |_, _| Ok(()),
2041                    |_, fields| {
2042                        requests += 1;
2043                        let mut reply = download_reply(fields, &descriptor, &ciphertext);
2044                        if !change_later || requests == 2 {
2045                            reply.insert(
2046                                "rawfilemd5".into(),
2047                                b"0123456789abcdef0123456789abcdef".to_vec(),
2048                            );
2049                        }
2050                        Ok(reply)
2051                    }
2052                )
2053                .is_err());
2054            assert_eq!(requests, 2);
2055        }
2056        let mut wrong_source = ciphertext.clone();
2057        wrong_source[0] ^= 1;
2058        assert!(client()
2059            .download_with_transport(
2060                &descriptor,
2061                |_, _| Ok(()),
2062                |_, fields| Ok(download_reply(fields, &descriptor, &wrong_source))
2063            )
2064            .is_err());
2065        let mut wrong_size = descriptor.clone();
2066        wrong_size.expected_bytes = Some(descriptor.expected_bytes.unwrap() + 1);
2067        wrong_size.strict_size = false;
2068        assert!(client()
2069            .download_with_transport(
2070                &wrong_size,
2071                |_, _| Ok(()),
2072                |_, fields| Ok(download_reply(fields, &wrong_size, &ciphertext))
2073            )
2074            .is_err());
2075    }
2076    #[test]
2077    fn cancellation_and_transport_failure_never_start_another_download_request() {
2078        let (descriptor, _, ciphertext) =
2079            download_fixture(512 * 1024 + 17, MediaQuality::LargeFile);
2080        for stop in [1, 2, 3, 4] {
2081            let mut callbacks = 0;
2082            let mut requests = 0;
2083            let outcome = client().download_with_transport(
2084                &descriptor,
2085                |_, _| {
2086                    callbacks += 1;
2087                    ensure!(callbacks != stop, "cancelled");
2088                    Ok(())
2089                },
2090                |_, fields| {
2091                    requests += 1;
2092                    Ok(download_reply(fields, &descriptor, &ciphertext))
2093                },
2094            );
2095            assert!(outcome.is_err());
2096            assert_eq!(callbacks, stop);
2097            assert_eq!(requests, stop / 2);
2098        }
2099        let mut requests = 0;
2100        assert!(client()
2101            .download_with_transport(
2102                &descriptor,
2103                |_, _| Ok(()),
2104                |_, _| {
2105                    requests += 1;
2106                    bail!("transport failure")
2107                }
2108            )
2109            .is_err());
2110        assert_eq!(requests, 1);
2111    }
2112    #[test]
2113    fn normal_download_keeps_optional_sequence_and_original_limits() {
2114        for quality in [
2115            MediaQuality::Original,
2116            MediaQuality::Standard,
2117            MediaQuality::Thumbnail,
2118            MediaQuality::Video,
2119            MediaQuality::File,
2120        ] {
2121            let (descriptor, raw, ciphertext) = download_fixture(65536 + 17, quality);
2122            let mut requests = 0;
2123            let downloaded = client()
2124                .download_with_transport(
2125                    &descriptor,
2126                    |_, _| Ok(()),
2127                    |path, fields| {
2128                        requests += 1;
2129                        assert_eq!(path, "/download");
2130                        assert_eq!(
2131                            integer(fields, "rangeend")? - integer(fields, "rangestart")? + 1,
2132                            if requests == 1 { 65536 } else { 32 }
2133                        );
2134                        let mut reply = download_reply(fields, &descriptor, &ciphertext);
2135                        reply.remove("seq");
2136                        Ok(reply)
2137                    },
2138                )
2139                .unwrap();
2140            assert_eq!(downloaded.bytes, raw);
2141            assert_eq!(requests, 2);
2142            assert!(client()
2143                .download_with_transport(
2144                    &descriptor,
2145                    |_, _| Ok(()),
2146                    |_, fields| {
2147                        let mut reply = download_reply(fields, &descriptor, &ciphertext);
2148                        put(&mut reply, "seq", 99);
2149                        Ok(reply)
2150                    }
2151                )
2152                .is_err());
2153            assert!(client()
2154                .download_with_transport(
2155                    &descriptor,
2156                    |_, _| Ok(()),
2157                    |_, fields| {
2158                        let mut reply = download_reply(fields, &descriptor, &ciphertext);
2159                        put(&mut reply, "totalsize", MAX_CDN_TRANSFER_BYTES + 32);
2160                        Ok(reply)
2161                    }
2162                )
2163                .is_err());
2164        }
2165        assert!(unpack_cdn(&vec![0; MAX_CDN_BODY + 1]).is_err());
2166    }
2167    #[test]
2168    fn large_descriptor_limits_fail_before_transport() {
2169        let (descriptor, _, _) = download_fixture(16, MediaQuality::LargeFile);
2170        for expected in [
2171            None,
2172            Some(0),
2173            Some(MAX_LARGE_FILE_BYTES as u64 + 1),
2174            Some(u64::MAX),
2175        ] {
2176            let mut descriptor = descriptor.clone();
2177            descriptor.expected_bytes = expected;
2178            let mut requests = 0;
2179            assert!(client()
2180                .download_with_transport(
2181                    &descriptor,
2182                    |_, _| Ok(()),
2183                    |_, _| {
2184                        requests += 1;
2185                        bail!("must not request")
2186                    }
2187                )
2188                .is_err());
2189            assert_eq!(requests, 0);
2190        }
2191    }
2192
2193    #[test]
2194    fn legacy_completion_receipt_survives_cancellation_after_final_ack() {
2195        let prepared = PreparedUpload::prepare(
2196            b"synthetic source",
2197            MediaQuality::File,
2198            "source.bin",
2199            "filehelper",
2200        )
2201        .unwrap();
2202        let cancelled = std::cell::Cell::new(false);
2203        let calls = std::cell::Cell::new(0);
2204        let upload = client()
2205            .upload_with_transport(
2206                &prepared,
2207                |_, _| {
2208                    ensure!(!cancelled.get(), "cancelled");
2209                    Ok(())
2210                },
2211                |path, fields| {
2212                    assert_eq!(path, "/uploadv3");
2213                    calls.set(calls.get() + 1);
2214                    let mut reply = CdnFields::new();
2215                    put(&mut reply, "retcode", 0);
2216                    for key in ["seq", "rangestart", "rangeend"] {
2217                        reply.insert(key.into(), fields[key].clone());
2218                    }
2219                    reply.insert("fileid".into(), b"synthetic-completed-object".to_vec());
2220                    cancelled.set(true);
2221                    Ok(reply)
2222                },
2223            )
2224            .unwrap();
2225        assert!(cancelled.get());
2226        assert_eq!(calls.get(), 1);
2227        assert_eq!(upload.descriptor.file_id, "synthetic-completed-object");
2228    }
2229
2230    #[test]
2231    fn paired_video_preserves_final_receipt_and_does_not_admit_large_normal() {
2232        let mut thumbnail = Vec::new();
2233        image::codecs::jpeg::JpegEncoder::new(&mut thumbnail)
2234            .encode_image(&image::DynamicImage::new_rgb8(16, 12))
2235            .unwrap();
2236        let p = PreparedVideoUpload::with_material(
2237            b"\0\0\0\x0cftypisom",
2238            &thumbnail,
2239            "normal.mp4",
2240            "filehelper",
2241            &[0x31; 16],
2242            "00112233445566778899aabbccddeeff",
2243        )
2244        .unwrap();
2245        let cancelled = std::cell::Cell::new(false);
2246        let uploaded = client()
2247            .upload_video_with_transport(
2248                &p,
2249                |_, _| {
2250                    ensure!(!cancelled.get(), "cancelled");
2251                    Ok(())
2252                },
2253                |path, fields| {
2254                    assert_eq!(path, "/uploadv3");
2255                    assert!(fields.contains_key("thumbdata"));
2256                    assert_eq!(integer(fields, "filetype")?, 4);
2257                    let mut reply = CdnFields::new();
2258                    put(&mut reply, "retcode", 0);
2259                    for key in ["seq", "rangestart", "rangeend"] {
2260                        reply.insert(key.into(), fields[key].clone());
2261                    }
2262                    reply.insert("fileid".into(), b"paired-object".to_vec());
2263                    cancelled.set(true);
2264                    Ok(reply)
2265                },
2266            )
2267            .unwrap();
2268        assert!(uploaded.original.is_none());
2269        assert_eq!(uploaded.video.descriptor.file_id, "paired-object");
2270        assert_eq!(uploaded.thumbnail.descriptor.file_id, "paired-object");
2271        let mut altered = p;
2272        altered.video.plaintext_bytes = MAX_NATIVE_UPLOAD_BYTES as u32 + 1;
2273        assert!(client()
2274            .upload_video_with_transport(
2275                &altered,
2276                |_, _| Ok(()),
2277                |_, _| { panic!("Large normal video reached legacy transport") }
2278            )
2279            .is_err());
2280    }
2281    #[test]
2282    fn boundary_matrix_keeps_ciphertext_and_media_kind_limits_separate() {
2283        let boundary = MAX_CDN_TRANSFER_BYTES;
2284        for (size, padded, legacy) in [
2285            (boundary - 17, boundary - 16, Some("/uploadv3")),
2286            (boundary - 16, boundary, None),
2287            (boundary - 1, boundary, None),
2288            (boundary, boundary + 16, None),
2289            (boundary + 1, boundary + 16, None),
2290            (MAX_LARGE_FILE_BYTES, MAX_LARGE_FILE_BYTES + 16, None),
2291            (MAX_LARGE_FILE_BYTES + 1, MAX_LARGE_FILE_BYTES + 16, None),
2292        ] {
2293            assert_eq!((size / 16 + 1) * 16, padded);
2294            let kind = if size > boundary {
2295                MediaQuality::LargeFile
2296            } else {
2297                MediaQuality::File
2298            };
2299            assert_eq!(legacy_upload_path(kind, size, padded).ok(), legacy);
2300            assert_eq!(
2301                size <= upload_plaintext_limit(kind),
2302                size <= MAX_LARGE_FILE_BYTES
2303            );
2304            assert_eq!(
2305                size <= upload_plaintext_limit(MediaQuality::Original),
2306                size <= MAX_NATIVE_UPLOAD_BYTES
2307            );
2308            assert_eq!(
2309                size <= upload_plaintext_limit(MediaQuality::Video),
2310                size <= wechat_media::MAX_VIDEO_SOURCE_BYTES
2311            );
2312        }
2313        assert_eq!(MAX_LARGE_FILE_BYTES, 536_870_912);
2314        assert_eq!(MAX_NATIVE_UPLOAD_BYTES, 26_214_383);
2315        assert_eq!(MAX_CDN_TRANSFER_BYTES, 26_214_400);
2316    }
2317    #[test]
2318    fn multiphase_paths_bind_account_sequence_type_and_phase() {
2319        for filetype in [4, 5, 7] {
2320            for phase in ["apply", "uploadblock", "fin", "complete"] {
2321                assert!(large_file_path(
2322                    &format!("/cdn/12345/3/{filetype}/{phase}"),
2323                    12345,
2324                    3
2325                ));
2326            }
2327        }
2328        for path in [
2329            "/cdn/999/3/7/apply",
2330            "/cdn/12345/2/7/apply",
2331            "/cdn/12345/03/7/apply",
2332            "/cdn/12345/0/7/apply",
2333            "/cdn/12345/3/3/apply",
2334            "/cdn/12345/3/7/unknown",
2335            "/cdn/12345/3/7/apply?x=1",
2336            "/cdn/12345/3/7/apply/extra",
2337            "//cdn/12345/3/7/apply",
2338            "/cdn/12345/3/7/../apply",
2339        ] {
2340            assert!(!large_file_path(path, 12345, 3), "Accepted {path}");
2341        }
2342        assert!(!large_file_path("/cdn/12345/0/7/apply", 12345, 0));
2343    }
2344    #[test]
2345    fn cdn_codec_rejects_duplicate_and_truncated() {
2346        let fields = BTreeMap::from([
2347            ("retcode".to_string(), b"0".to_vec()),
2348            ("filedata".into(), vec![0, 1, 2, 255]),
2349        ]);
2350        let packed = pack_cdn(&fields).unwrap();
2351        assert_eq!(unpack_cdn(&packed).unwrap(), fields);
2352        let one = pack_cdn(&BTreeMap::from([("x".into(), vec![1])])).unwrap();
2353        for n in 1..one.len() {
2354            assert!(unpack_cdn(&one[..n]).is_err());
2355        }
2356        let duplicate = [one.clone(), one].concat();
2357        assert!(unpack_cdn(&duplicate).is_err());
2358    }
2359    #[test]
2360    fn prepared_upload_roundtrip() {
2361        let p = PreparedUpload::prepare(
2362            b"synthetic attachment",
2363            MediaQuality::File,
2364            "test.txt",
2365            "filehelper",
2366        )
2367        .unwrap();
2368        p.validate().unwrap();
2369        assert_eq!(
2370            decrypt_cdn(&p.encrypted, &p.aes_key).unwrap(),
2371            b"synthetic attachment"
2372        );
2373        let mut corrupt = p.clone();
2374        corrupt.encrypted[0] ^= 1;
2375        assert!(corrupt.validate().is_err());
2376    }
2377    #[test]
2378    fn nonpublic_addresses_rejected() {
2379        for ip in [
2380            "0.1.2.3",
2381            "10.0.0.1",
2382            "100.64.0.1",
2383            "127.0.0.1",
2384            "169.254.1.1",
2385            "172.16.0.1",
2386            "192.168.0.1",
2387            "198.18.0.1",
2388            "192.0.2.1",
2389            "198.51.100.1",
2390            "203.0.113.1",
2391            "224.1.2.3",
2392        ] {
2393            assert!(!public_ipv4(ip.parse().unwrap()));
2394        }
2395        assert!(public_ipv4("8.8.8.8".parse().unwrap()));
2396    }
2397    #[test]
2398    fn upload_reply_correlates_ranges() {
2399        let mut f = CdnFields::from([
2400            ("retcode".into(), b"0".to_vec()),
2401            ("seq".into(), b"2".to_vec()),
2402        ]);
2403        assert!(validate_upload_reply(&f, 1, 0, 15, &mut None).is_err());
2404        put(&mut f, "seq", 1);
2405        put(&mut f, "rangestart", 0);
2406        put(&mut f, "rangeend", 15);
2407        f.insert("fileid".into(), b"synthetic-file-id".to_vec());
2408        let mut id = None;
2409        validate_upload_reply(&f, 1, 0, 15, &mut id).unwrap();
2410        assert_eq!(id, Some("synthetic-file-id".into()));
2411    }
2412    #[test]
2413    fn prepared_jpeg_variants_are_real_and_independent() {
2414        fn jpeg(w: u32, h: u32) -> Vec<u8> {
2415            let rgb = image::RgbImage::from_pixel(w, h, image::Rgb([12, 40, 80]));
2416            let mut out = vec![];
2417            image::codecs::jpeg::JpegEncoder::new(&mut out)
2418                .encode_image(&rgb)
2419                .unwrap();
2420            out
2421        }
2422        let p = PreparedImageUpload::prepare(
2423            &jpeg(64, 48),
2424            &jpeg(32, 24),
2425            &jpeg(16, 12),
2426            "image.jpg",
2427            "filehelper",
2428        )
2429        .unwrap();
2430        p.validate().unwrap();
2431        assert_eq!((p.width, p.height), (64, 48));
2432        assert_eq!((p.thumbnail.width, p.thumbnail.height), (16, 12));
2433        assert!(PreparedImageUpload::prepare(
2434            &jpeg(8, 8),
2435            &jpeg(16, 16),
2436            &jpeg(4, 4),
2437            "image.jpg",
2438            "filehelper"
2439        )
2440        .is_err());
2441        assert!(PreparedImageUpload::prepare(
2442            b"not image",
2443            &jpeg(4, 4),
2444            &jpeg(2, 2),
2445            "image.jpg",
2446            "filehelper"
2447        )
2448        .is_err());
2449    }
2450}