1use 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 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 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 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 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 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#[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#[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
811fn 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 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 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 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 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 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 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}