1use super::*;
2
3const MAX_UPLOAD_TOKEN_BYTES: usize = 65536;
4const BLOCKS_PER_GROUP: usize = 128;
5const MAX_BLOCKS: usize = (MAX_LARGE_FILE_BYTES + 16).div_ceil(512 * 1024);
6
7fn acknowledged_bitmap(next_block: usize) -> Vec<u8> {
11 let group = next_block / BLOCKS_PER_GROUP;
12 let first_group = group.saturating_sub(2);
13 let mut bitmap = vec![0; (group - first_group + 1) * 16];
14 for bit in 0..next_block - first_group * BLOCKS_PER_GROUP {
15 bitmap[bit / 8] |= 1 << (bit % 8);
16 }
17 bitmap
18}
19
20impl CdnClient {
21 pub fn upload_large_file_with_progress(
24 &self,
25 prepared: &PreparedUpload,
26 task_id: u64,
27 progress: impl FnMut(u64, Option<u64>) -> Result<()>,
28 ) -> Result<UploadedMedia> {
29 ensure!(
30 matches!(prepared.kind, MediaQuality::File | MediaQuality::LargeFile),
31 "Expected an ordinary file upload"
32 );
33 upload_large_file(
34 &self.config,
35 prepared,
36 task_id,
37 |path, fields| self.post(path, fields),
38 progress,
39 )
40 }
41
42 pub fn upload_video_original_with_progress(
46 &self,
47 prepared: &PreparedUpload,
48 task_id: u64,
49 progress: impl FnMut(u64, Option<u64>) -> Result<()>,
50 ) -> Result<UploadedMedia> {
51 upload_video_original(
52 self,
53 prepared,
54 task_id,
55 |path, fields| self.post(path, fields),
56 progress,
57 )
58 }
59}
60
61fn upload_video_original(
62 client: &CdnClient,
63 prepared: &PreparedUpload,
64 task_id: u64,
65 post: impl FnMut(&str, &CdnFields) -> Result<CdnFields>,
66 progress: impl FnMut(u64, Option<u64>) -> Result<()>,
67) -> Result<UploadedMedia> {
68 ensure!(
69 prepared.kind == MediaQuality::Video && task_id != 0,
70 "Expected an original video upload and task ID"
71 );
72 prepared.validate()?;
73 if prepared.encrypted.len() < MAX_CDN_TRANSFER_BYTES {
74 client.upload_with_transport(prepared, progress, post)
75 } else {
76 upload_large_file(&client.config, prepared, task_id, post, progress)
77 }
78}
79
80fn common_fields(
81 conf: &CdnConfig,
82 prepared: &PreparedUpload,
83 task_id: u64,
84 sequence: u32,
85) -> CdnFields {
86 let mut fields = CdnFields::new();
87 for (key, value) in [
88 ("ver", conf.version as u64),
89 ("seq", sequence as u64),
90 ("weixinnum", conf.uin),
91 ("nettype", 1),
92 ("ipseq", 0),
93 ("retrycnt", 0),
94 ("lastretcode", 0),
95 ("waitsec", 0),
96 ("clientversion", conf.client_version as u64),
97 ("taskid", task_id),
98 ("from", 0),
99 ("bizid", 1),
100 ("appid", 0),
101 ("filetype", u64::from(prepared.kind.filetype())),
102 ("optype", 0),
103 ("expectbizid", 0),
104 ("expectappid", 0),
105 ("expectfiletype", u64::from(prepared.kind.filetype())),
106 ("expectoptype", 0),
107 ("frontid", conf.front_id as u64),
108 ] {
109 put(&mut fields, key, value);
110 }
111 fields.insert("authkey".into(), conf.auth_key.clone());
112 fields.insert(
113 "clientostype".into(),
114 conf.client_os_type.as_bytes().to_vec(),
115 );
116 fields.insert("filekey".into(), prepared.file_key.as_bytes().to_vec());
117 if let Some(location) = conf.location {
118 for (key, value) in [
119 ("province", location.province),
120 ("city", location.city),
121 ("isp", location.isp),
122 ] {
123 put(&mut fields, key, value);
124 }
125 }
126 fields
127}
128
129fn validate_reply(reply: &CdnFields, sequence: u32, version: u32) -> Result<()> {
130 ensure!(
131 integer(reply, "seq")? == i64::from(sequence),
132 "Large-file reply sequence mismatch"
133 );
134 let code = integer(reply, "retcode")?;
135 ensure!(code == 0, "Large-file upload returned {code}");
136 for key in ["isretry", "retrysec", "isoverload", "isgetcdn"] {
137 ensure!(
138 integer(reply, key)? == 0,
139 "Unsupported large-file upload retry or routing control"
140 );
141 }
142 ensure!(
143 integer(reply, "ver")? == i64::from(version),
144 "Large-file reply version mismatch"
145 );
146 Ok(())
147}
148
149fn opaque_field(reply: &CdnFields, key: &str, max_bytes: usize) -> Result<Vec<u8>> {
150 let value = reply
151 .get(key)
152 .ok_or_else(|| anyhow::anyhow!("Missing large-file {key}"))?;
153 ensure!(
154 !value.is_empty() && value.len() <= max_bytes,
155 "Invalid large-file {key} length"
156 );
157 Ok(value.clone())
158}
159
160fn upload_large_file(
161 conf: &CdnConfig,
162 prepared: &PreparedUpload,
163 task_id: u64,
164 mut post: impl FnMut(&str, &CdnFields) -> Result<CdnFields>,
165 mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
166) -> Result<UploadedMedia> {
167 ensure!(
168 ((prepared.kind == MediaQuality::File
169 && prepared.plaintext_bytes as usize > MAX_NATIVE_UPLOAD_BYTES
170 && prepared.plaintext_bytes as usize <= MAX_CDN_TRANSFER_BYTES)
171 || (prepared.kind == MediaQuality::LargeFile
172 && prepared.plaintext_bytes as usize > MAX_CDN_TRANSFER_BYTES
173 && prepared.plaintext_bytes as usize <= MAX_LARGE_FILE_BYTES)
174 || (prepared.kind == MediaQuality::Video
175 && prepared.plaintext_bytes as usize > MAX_NATIVE_UPLOAD_BYTES
176 && prepared.plaintext_bytes as usize <= wechat_media::MAX_VIDEO_SOURCE_BYTES))
177 && prepared.encrypted.len() >= MAX_CDN_TRANSFER_BYTES
178 && task_id != 0,
179 "Unsupported large-file upload profile"
180 );
181 conf.validate()?;
182 ensure!(
183 conf.location.is_some(),
184 "Missing authenticated CDN location"
185 );
186 prepared.validate()?;
187 let total = prepared.encrypted.len() as u64;
188 let mut sequence = 0u32;
189 let mut exchange =
192 |phase: &str,
193 extra: CdnFields,
194 acknowledged: u64,
195 progress: &mut dyn FnMut(u64, Option<u64>) -> Result<()>| {
196 progress(acknowledged, Some(total))?;
197 sequence += 1;
198 let mut fields = common_fields(conf, prepared, task_id, sequence);
199 fields.extend(extra);
200 let path = format!(
201 "/cdn/{}/{sequence}/{}/{phase}",
202 conf.uin,
203 prepared.kind.filetype()
204 );
205 let reply = post(&path, &fields)?;
206 validate_reply(&reply, sequence, conf.version)?;
207 Ok::<_, anyhow::Error>(reply)
208 };
209
210 let mut apply = CdnFields::new();
211 for (key, value) in [
212 ("apptype", 1),
213 ("chatnum", 1),
214 ("reqtype", 0),
215 ("type", u64::from(prepared.kind.filetype())),
216 ("prepare", 0),
217 ("num", 1),
218 ("type_0", u64::from(prepared.kind.filetype())),
219 ("cpr_0", 0),
220 ("size_0", total),
221 ("rawsize_0", prepared.plaintext_bytes as u64),
222 ("format_0", 0),
223 ] {
224 put(&mut apply, key, value);
225 }
226 apply.insert(
227 "chatid".into(),
228 encrypt_recipient(&prepared.destination)?.into_bytes(),
229 );
230 apply.insert("rawfilemd5_0".into(), prepared.md5.as_bytes().to_vec());
231 if let Some((_, extension)) = prepared.file_name.rsplit_once('.') {
232 if !extension.is_empty() {
233 apply.insert("ext_0".into(), extension.as_bytes().to_vec());
234 }
235 }
236 let reply = exchange("apply", apply, 0, &mut progress)?;
237 ensure!(
238 integer(&reply, "hit")? == 0,
239 "Large-file cache hits require unsupported key recovery"
240 );
241 ensure!(
242 integer(&reply, "type")? == i64::from(prepared.kind.filetype())
243 && integer(&reply, "num")? == 1
244 && integer(&reply, "type_0")? == i64::from(prepared.kind.filetype())
245 && integer(&reply, "fin_0")? == 0,
246 "Unsupported large-file apply result"
247 );
248 let block_size = usize::try_from(integer(&reply, "blocksize_0")?)?;
249 ensure!(
250 [512 * 1024, 1024 * 1024].contains(&block_size)
251 && prepared.encrypted.len().div_ceil(block_size) <= MAX_BLOCKS,
252 "Unsupported large-file block size"
253 );
254 let upinfo = opaque_field(&reply, "upinfo_0", MAX_UPLOAD_TOKEN_BYTES)?;
255 progress(0, Some(total))?;
256
257 let mut acknowledged = 0u64;
258 let mut etags = Vec::new();
259 for (index, chunk) in prepared.encrypted.chunks(block_size).enumerate() {
260 let mut fields = CdnFields::new();
261 put(&mut fields, "alreadyupsize", acknowledged);
262 fields.insert("alreadyupbit".into(), acknowledged_bitmap(index));
263 fields.insert("upinfo".into(), upinfo.clone());
264 put(&mut fields, "offset", acknowledged);
265 fields.insert("blockmd5".into(), hex::encode(md5(chunk)).into_bytes());
266 fields.insert("blockdata".into(), chunk.to_vec());
267 let reply = exchange("uploadblock", fields, acknowledged, &mut progress)?;
268 ensure!(
269 u64::try_from(integer(&reply, "offset")?)? == acknowledged
270 && integer(&reply, "fin")? == 0,
271 "Large-file block acknowledgement mismatch"
272 );
273 etags.push(opaque_field(&reply, "etag", 8192)?);
274 acknowledged += chunk.len() as u64;
275 progress(acknowledged, Some(total))?;
276 }
277 ensure!(acknowledged == total, "Incomplete large-file upload");
278
279 let mut finish = CdnFields::new();
280 finish.insert("upinfo".into(), upinfo);
281 put(&mut finish, "blocksum", etags.len());
282 for (index, etag) in etags.into_iter().enumerate() {
283 finish.insert(format!("etag_{index}"), etag);
284 }
285 let reply = exchange("fin", finish, acknowledged, &mut progress)?;
286 let finished_upinfo = opaque_field(&reply, "upinfo", MAX_UPLOAD_TOKEN_BYTES)?;
287 progress(acknowledged, Some(total))?;
288 let mut complete = CdnFields::new();
289 put(&mut complete, "type", prepared.kind.filetype());
290 put(&mut complete, "num", 1);
291 complete.insert("upinfo_0".into(), finished_upinfo);
292 let reply = exchange("complete", complete, acknowledged, &mut progress)?;
293 let file_id = String::from_utf8(opaque_field(&reply, "fileid", 8192)?)?;
294 ensure!(
295 !file_id.chars().any(|c| c.is_control()),
296 "Invalid large-file identifier"
297 );
298 Ok(UploadedMedia {
301 descriptor: MediaDescriptor {
302 file_id,
303 aes_key: prepared.aes_key.clone(),
304 quality: prepared.kind,
305 expected_bytes: Some(prepared.plaintext_bytes as u64),
306 expected_md5: Some(prepared.md5.clone()),
307 strict_size: true,
308 expires_at: None,
309 },
310 plaintext_bytes: prepared.plaintext_bytes,
311 md5: prepared.md5.clone(),
312 sha256: prepared.sha256.clone(),
313 file_name: prepared.file_name.clone(),
314 width: None,
315 height: None,
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::{cell::RefCell, sync::OnceLock};
323
324 #[test]
325 fn acknowledgement_windows_cross_multiple_groups_without_wrapping() {
326 assert_eq!(acknowledged_bitmap(0), vec![0; 16]);
327 let mut before_boundary = vec![255; 16];
328 before_boundary[15] = 127;
329 assert_eq!(acknowledged_bitmap(127), before_boundary);
330 assert_eq!(
331 acknowledged_bitmap(128),
332 [vec![255; 16], vec![0; 16]].concat()
333 );
334 assert_eq!(
335 acknowledged_bitmap(256),
336 [vec![255; 32], vec![0; 16]].concat()
337 );
338 for index in [384, 512, 1024] {
339 assert_eq!(
340 acknowledged_bitmap(index),
341 [vec![255; 32], vec![0; 16]].concat()
342 );
343 }
344 let mut after_boundary = [vec![255; 32], vec![0; 16]].concat();
345 after_boundary[32] = 1;
346 assert_eq!(acknowledged_bitmap(513), after_boundary);
347 assert_eq!(MAX_BLOCKS, 1025);
348 }
349
350 #[test]
351 fn final_request_serializes_all_maximum_file_etags_in_order() {
352 let mut fields = CdnFields::new();
353 put(&mut fields, "blocksum", MAX_BLOCKS);
354 fields.insert("upinfo".into(), b"synthetic-finish-context".to_vec());
355 for index in 0..MAX_BLOCKS {
356 fields.insert(
357 format!("etag_{index}"),
358 format!("synthetic-{index}").into_bytes(),
359 );
360 }
361 let wire = pack_cdn(&fields).unwrap();
362 let mut cursor = crate::mmtls::Cursor::new(&wire);
363 let mut decoded = CdnFields::new();
364 for _ in 0..MAX_BLOCKS + 2 {
365 let key = std::str::from_utf8(cursor.sized(4).unwrap())
366 .unwrap()
367 .to_owned();
368 decoded.insert(key, cursor.sized(4).unwrap().to_vec());
369 }
370 cursor.end().unwrap();
371 assert_eq!(integer(&decoded, "blocksum").unwrap(), 1025);
372 for index in 0..MAX_BLOCKS {
373 assert_eq!(
374 decoded[&format!("etag_{index}")],
375 format!("synthetic-{index}").as_bytes()
376 );
377 }
378 fields.extend((MAX_BLOCKS..2048).map(|i| (format!("etag_{i}"), vec![1])));
379 assert!(pack_cdn(&fields).is_err());
380 }
381
382 fn config() -> CdnConfig {
383 CdnConfig {
384 version: 1,
385 uin: 12345,
386 client_version: 456,
387 client_os_type: "test-client".into(),
388 auth_key: b"test-credential".to_vec(),
389 server_ips: vec![Ipv4Addr::new(1, 1, 1, 1)],
390 front_id: 789,
391 location: Some(CdnLocation {
392 province: 1234,
393 city: 56,
394 isp: 78,
395 }),
396 }
397 }
398
399 fn prepared() -> &'static PreparedUpload {
400 static VALUE: OnceLock<PreparedUpload> = OnceLock::new();
401 VALUE.get_or_init(|| {
402 PreparedUpload::with_material(
403 &vec![0x5a; MAX_CDN_TRANSFER_BYTES + 17],
404 MediaQuality::LargeFile,
405 "boundary.fixture.bin",
406 "filehelper",
407 &[0x31; 16],
408 "00112233445566778899aabbccddeeff",
409 )
410 .unwrap()
411 })
412 }
413
414 struct Peer<'a> {
417 prepared: &'a PreparedUpload,
418 block_size: usize,
419 requests: usize,
420 }
421
422 impl<'a> Peer<'a> {
423 fn new(prepared: &'a PreparedUpload, block_size: usize) -> Self {
424 Self {
425 prepared,
426 block_size,
427 requests: 0,
428 }
429 }
430
431 fn blocks(&self) -> usize {
432 self.prepared.encrypted.len().div_ceil(self.block_size)
433 }
434
435 fn respond(&mut self, path: &str, fields: &CdnFields) -> Result<CdnFields> {
436 self.requests += 1;
437 let sequence = self.requests;
438 let kind = self.prepared.kind.filetype();
439 let phase = match sequence {
440 1 => "apply",
441 n if n <= self.blocks() + 1 => "uploadblock",
442 n if n == self.blocks() + 2 => "fin",
443 n if n == self.blocks() + 3 => "complete",
444 _ => panic!("Request after completion"),
445 };
446 assert_eq!(path, format!("/cdn/12345/{sequence}/{kind}/{phase}"));
447 assert_eq!(integer(fields, "seq")?, sequence as i64);
448 assert_eq!(integer(fields, "taskid")?, 987654321);
449 assert_eq!(integer(fields, "filetype")?, i64::from(kind));
450 assert_eq!(integer(fields, "expectfiletype")?, i64::from(kind));
451 assert_eq!(integer(fields, "frontid")?, 789);
452 for key in ["ipseq", "retrycnt", "lastretcode", "waitsec"] {
453 assert_eq!(integer(fields, key)?, 0);
454 }
455 assert_eq!(integer(fields, "province")?, 1234);
456 assert_eq!(integer(fields, "city")?, 56);
457 assert_eq!(integer(fields, "isp")?, 78);
458 assert_eq!(fields["authkey"], b"test-credential");
459 assert_eq!(fields["filekey"], self.prepared.file_key.as_bytes());
460 assert!(!fields.contains_key("aeskey"));
461 assert!(!fields.contains_key("signature"));
462 let mut reply = CdnFields::new();
463 for (key, value) in [
464 ("ver", 1),
465 ("seq", sequence),
466 ("retcode", 0),
467 ("isretry", 0),
468 ("retrysec", 0),
469 ("isoverload", 0),
470 ("isgetcdn", 0),
471 ] {
472 put(&mut reply, key, value);
473 }
474 match phase {
475 "apply" => {
476 assert_eq!(integer(fields, "type")?, i64::from(kind));
477 assert_eq!(integer(fields, "type_0")?, i64::from(kind));
478 assert_eq!(integer(fields, "num")?, 1);
479 assert_eq!(integer(fields, "apptype")?, 1);
480 assert_eq!(integer(fields, "chatnum")?, 1);
481 assert_eq!(
482 integer(fields, "size_0")?,
483 self.prepared.encrypted.len() as i64
484 );
485 assert_eq!(
486 integer(fields, "rawsize_0")?,
487 i64::from(self.prepared.plaintext_bytes)
488 );
489 assert_eq!(fields["rawfilemd5_0"], self.prepared.md5.as_bytes());
490 assert_eq!(
491 fields["ext_0"],
492 self.prepared
493 .file_name
494 .rsplit('.')
495 .next()
496 .unwrap()
497 .as_bytes()
498 );
499 assert_eq!(
500 fields["chatid"],
501 encrypt_recipient("filehelper")?.as_bytes()
502 );
503 for (key, value) in [
504 ("hit", 0),
505 ("type", kind as usize),
506 ("num", 1),
507 ("type_0", kind as usize),
508 ("fin_0", 0),
509 ("blocksize_0", self.block_size),
510 ] {
511 put(&mut reply, key, value);
512 }
513 reply.insert("upinfo_0".into(), b"apply-token".to_vec());
514 }
515 "uploadblock" => {
516 let index = sequence - 2;
517 let offset = index * self.block_size;
518 let end = (offset + self.block_size).min(self.prepared.encrypted.len());
519 let expected = &self.prepared.encrypted[offset..end];
520 assert_eq!(integer(fields, "offset")?, offset as i64);
521 assert_eq!(integer(fields, "alreadyupsize")?, offset as i64);
522 assert_eq!(fields["upinfo"], b"apply-token");
523 assert_eq!(fields["blockdata"], expected);
524 assert_eq!(fields["blockmd5"], hex::encode(md5(expected)).as_bytes());
525 let bitmap: Vec<u8> = (0..128)
526 .collect::<Vec<_>>()
527 .chunks(8)
528 .map(|bits| {
529 bits.iter().fold(0, |value, bit| {
530 value | (u8::from(*bit < index) << (bit % 8))
531 })
532 })
533 .collect();
534 assert_eq!(fields["alreadyupbit"], bitmap);
535 put(&mut reply, "offset", offset);
536 put(&mut reply, "fin", 0);
537 reply.insert("etag".into(), format!("etag-{index}").into_bytes());
538 }
539 "fin" => {
540 assert_eq!(fields["upinfo"], b"apply-token");
541 assert_eq!(integer(fields, "blocksum")?, self.blocks() as i64);
542 for index in 0..self.blocks() {
543 assert_eq!(
544 fields[&format!("etag_{index}")],
545 format!("etag-{index}").as_bytes()
546 );
547 }
548 reply.insert("upinfo".into(), b"finish-token".to_vec());
549 }
550 "complete" => {
551 assert_eq!(integer(fields, "type")?, i64::from(kind));
552 assert_eq!(integer(fields, "num")?, 1);
553 assert_eq!(fields["upinfo_0"], b"finish-token");
554 reply.insert("fileid".into(), b"completed-object".to_vec());
555 }
556 _ => unreachable!(),
557 }
558 Ok(reply)
559 }
560 }
561
562 #[test]
563 fn transfers_ordered_blocks_and_uses_finish_token() {
564 let prepared = prepared();
565 for block_size in [512 * 1024, 1024 * 1024] {
566 let mut peer = Peer::new(prepared, block_size);
567 let events = RefCell::new(Vec::new());
568 let progress = RefCell::new(Vec::new());
569 let result = upload_large_file(
570 &config(),
571 prepared,
572 987654321,
573 |path, fields| {
574 events.borrow_mut().push("request");
575 peer.respond(path, fields)
576 },
577 |done, total| {
578 events.borrow_mut().push("progress");
579 progress.borrow_mut().push(done);
580 assert_eq!(total, Some(prepared.encrypted.len() as u64));
581 Ok(())
582 },
583 )
584 .unwrap();
585 assert_eq!(peer.requests, peer.blocks() + 3);
586 let events = events.borrow();
587 for event in events[..events.len() - 2].chunks(3) {
588 assert_eq!(event, ["progress", "request", "progress"]);
589 }
590 assert_eq!(&events[events.len() - 2..], ["progress", "request"]);
591 let positions = progress.borrow();
592 assert!(positions.windows(2).all(|pair| pair[0] <= pair[1]));
593 assert_eq!(
594 positions.last().copied(),
595 Some(prepared.encrypted.len() as u64)
596 );
597 assert_eq!(result.descriptor.file_id, "completed-object");
598 assert_eq!(result.descriptor.quality, MediaQuality::LargeFile);
599 assert_eq!(
600 result.descriptor.expected_bytes,
601 Some(prepared.plaintext_bytes as u64)
602 );
603 assert_eq!(result.descriptor.aes_key, prepared.aes_key);
604 assert_eq!(result.md5, prepared.md5);
605 assert_eq!(result.sha256, prepared.sha256);
606 }
607 }
608
609 #[test]
610 fn standalone_original_video_uses_type_four_without_thumbnail() {
611 let mut raw = prepared().clone();
612 raw.kind = MediaQuality::Video;
613 raw.file_name = "original.mp4".into();
614 let cdn = CdnClient::new(config(), Duration::from_secs(1)).unwrap();
615 let cancelled = std::cell::Cell::new(false);
616 let mut peer = Peer::new(&raw, 1024 * 1024);
617 let result = upload_video_original(
618 &cdn,
619 &raw,
620 987654321,
621 |path, fields| {
622 assert!(!fields.contains_key("thumbdata"));
623 assert!(!fields.contains_key("type_1"));
624 let response = peer.respond(path, fields)?;
625 if path.ends_with("/complete") {
626 cancelled.set(true);
627 }
628 Ok(response)
629 },
630 |_, _| {
631 ensure!(!cancelled.get(), "cancelled");
632 Ok(())
633 },
634 )
635 .unwrap();
636 assert!(cancelled.get());
637 assert_eq!(result.descriptor.quality, MediaQuality::Video);
638 assert_eq!(
639 result.descriptor.expected_bytes,
640 Some(u64::from(raw.plaintext_bytes))
641 );
642 assert_eq!(result.descriptor.aes_key, raw.aes_key);
643 assert_eq!(result.md5, raw.md5);
644 assert_eq!(result.sha256, raw.sha256);
645 assert_eq!(peer.requests, peer.blocks() + 3);
646 assert!(cdn
647 .upload_large_file_with_progress(&raw, 987654321, |_, _| {
648 panic!("Video admitted by ordinary-file interface")
649 })
650 .is_err());
651 }
652
653 #[test]
654 fn small_original_video_uses_standalone_legacy_and_rejects_bad_material_locally() {
655 let raw = PreparedUpload::with_material(
656 b"\0\0\0\x0cftypisom",
657 MediaQuality::Video,
658 "original.mp4",
659 "filehelper",
660 &[0x31; 16],
661 "00112233445566778899aabbccddeeff",
662 )
663 .unwrap();
664 let cdn = CdnClient::new(config(), Duration::from_secs(1)).unwrap();
665 let mut calls = 0;
666 let result = upload_video_original(
667 &cdn,
668 &raw,
669 987654321,
670 |path, fields| {
671 calls += 1;
672 assert_eq!(path, "/uploadv3");
673 assert_eq!(integer(fields, "filetype")?, 4);
674 assert!(!fields.contains_key("thumbdata"));
675 assert_eq!(integer(fields, "hasthumb")?, 0);
676 let mut reply = CdnFields::new();
677 put(&mut reply, "retcode", 0);
678 for key in ["seq", "rangestart", "rangeend"] {
679 reply.insert(key.into(), fields[key].clone());
680 }
681 reply.insert("fileid".into(), b"small-original".to_vec());
682 Ok(reply)
683 },
684 |_, _| Ok(()),
685 )
686 .unwrap();
687 assert_eq!(calls, 1);
688 assert_eq!(result.descriptor.quality, MediaQuality::Video);
689 let mut over = raw.clone();
690 over.plaintext_bytes = wechat_media::MAX_VIDEO_SOURCE_BYTES as u32 + 1;
691 let mut wrong = raw.clone();
692 wrong.kind = MediaQuality::File;
693 let mut corrupt = raw.clone();
694 corrupt.encrypted[0] ^= 1;
695 for (value, task) in [
696 (&over, 987654321),
697 (&wrong, 987654321),
698 (&corrupt, 987654321),
699 (&raw, 0),
700 ] {
701 assert!(upload_video_original(
702 &cdn,
703 value,
704 task,
705 |_, _| { panic!("Invalid original video reached transport") },
706 |_, _| Ok(())
707 )
708 .is_err());
709 }
710 }
711
712 #[test]
713 fn original_video_padding_boundary_selects_one_carrier_without_fallback() {
714 let cdn = CdnClient::new(config(), Duration::from_secs(1)).unwrap();
715 for (size, expected_path) in [
716 (MAX_NATIVE_UPLOAD_BYTES, "/uploadv3"),
717 (MAX_NATIVE_UPLOAD_BYTES + 1, "/cdn/12345/1/4/apply"),
718 ] {
719 let raw = PreparedUpload::with_material(
720 &vec![0x5a; size],
721 MediaQuality::Video,
722 "original.mp4",
723 "filehelper",
724 &[0x31; 16],
725 "00112233445566778899aabbccddeeff",
726 )
727 .unwrap();
728 let mut calls = 0;
729 let error = upload_video_original(
730 &cdn,
731 &raw,
732 987654321,
733 |path, fields| {
734 calls += 1;
735 assert_eq!(path, expected_path);
736 assert_eq!(integer(fields, "filetype")?, 4);
737 bail!("isolated transport failure")
738 },
739 |_, _| Ok(()),
740 )
741 .err()
742 .unwrap();
743 assert_eq!(error.to_string(), "isolated transport failure");
744 assert_eq!(calls, 1);
745 }
746 }
747
748 #[test]
749 fn padding_band_and_exact_threshold_keep_filetype_five() {
750 for (plaintext, ciphertext, requests) in [
751 (MAX_CDN_TRANSFER_BYTES - 16, MAX_CDN_TRANSFER_BYTES, 28),
752 (MAX_CDN_TRANSFER_BYTES - 1, MAX_CDN_TRANSFER_BYTES, 28),
753 (MAX_CDN_TRANSFER_BYTES, MAX_CDN_TRANSFER_BYTES + 16, 29),
754 ] {
755 let prepared = PreparedUpload::with_material(
756 &vec![0x5a; plaintext],
757 MediaQuality::File,
758 "boundary.fixture.bin",
759 "filehelper",
760 &[0x31; 16],
761 "00112233445566778899aabbccddeeff",
762 )
763 .unwrap();
764 assert_eq!(prepared.encrypted.len(), ciphertext);
765 let legacy = CdnClient::new(config(), Duration::from_secs(1)).unwrap();
766 let rejected = legacy.upload_with_progress(&prepared, |_, _| {
767 panic!("Legacy transfer started for a multiphase file")
768 });
769 assert_eq!(
770 rejected.err().unwrap().to_string(),
771 "File requires multiphase upload"
772 );
773 let mut peer = Peer::new(&prepared, 1024 * 1024);
774 let result = upload_large_file(
775 &config(),
776 &prepared,
777 987654321,
778 |path, fields| peer.respond(path, fields),
779 |_, _| Ok(()),
780 )
781 .unwrap();
782 assert_eq!(result.descriptor.quality, MediaQuality::File);
783 assert_eq!(peer.requests, requests);
784 }
785 }
786
787 #[test]
788 fn completed_object_receipt_survives_late_cancellation() {
789 let cancelled = std::cell::Cell::new(false);
790 let mut peer = Peer::new(prepared(), 1024 * 1024);
791 let uploaded = upload_large_file(
792 &config(),
793 prepared(),
794 987654321,
795 |path, fields| {
796 let reply = peer.respond(path, fields)?;
797 if path.ends_with("/complete") {
798 cancelled.set(true);
799 }
800 Ok(reply)
801 },
802 |_, _| {
803 ensure!(!cancelled.get(), "cancelled");
804 Ok(())
805 },
806 )
807 .unwrap();
808 assert!(cancelled.get());
809 assert_eq!(uploaded.descriptor.file_id, "completed-object");
810 assert_eq!(peer.requests, peer.blocks() + 3);
811 }
812
813 #[test]
814 fn rejects_apply_changes_and_cache_hits_without_sending_blocks() {
815 for (key, value) in [
816 ("seq", 2),
817 ("ver", 2),
818 ("retcode", -1),
819 ("isretry", 1),
820 ("retrysec", 1),
821 ("isoverload", 1),
822 ("isgetcdn", 1),
823 ("hit", 1),
824 ("type_0", 5),
825 ("num", 2),
826 ("fin_0", 1),
827 ("blocksize_0", 2 * 1024 * 1024),
828 ("blocksize_0", -1),
829 ] {
830 let mut peer = Peer::new(prepared(), 1024 * 1024);
831 let result = upload_large_file(
832 &config(),
833 prepared(),
834 987654321,
835 |path, fields| {
836 let mut reply = peer.respond(path, fields)?;
837 put(&mut reply, key, value);
838 Ok(reply)
839 },
840 |_, _| Ok(()),
841 );
842 assert!(result.is_err(), "Accepted invalid {key}");
843 assert_eq!(peer.requests, 1);
844 }
845 }
846
847 #[test]
848 fn rejected_reply_preserves_server_code_without_trusting_wrong_sequence() {
849 let mut reply = CdnFields::new();
850 put(&mut reply, "seq", 1);
851 put(&mut reply, "ver", 0);
852 put(&mut reply, "retcode", -5103015);
853 assert_eq!(
854 validate_reply(&reply, 1, 1).unwrap_err().to_string(),
855 "Large-file upload returned -5103015"
856 );
857 put(&mut reply, "seq", 2);
858 assert_eq!(
859 validate_reply(&reply, 1, 1).unwrap_err().to_string(),
860 "Large-file reply sequence mismatch"
861 );
862 put(&mut reply, "seq", 1);
863 put(&mut reply, "retcode", 0);
864 for key in ["isretry", "retrysec", "isoverload", "isgetcdn"] {
865 put(&mut reply, key, 0);
866 }
867 assert_eq!(
868 validate_reply(&reply, 1, 1).unwrap_err().to_string(),
869 "Large-file reply version mismatch"
870 );
871 put(&mut reply, "isretry", 1);
872 assert_eq!(
873 validate_reply(&reply, 1, 1).unwrap_err().to_string(),
874 "Unsupported large-file upload retry or routing control"
875 );
876 put(&mut reply, "isretry", 0);
877 put(&mut reply, "ver", 1);
878 validate_reply(&reply, 1, 1).unwrap();
879 }
880
881 #[test]
882 fn missing_authenticated_location_stops_before_any_exchange() {
883 let mut conf = config();
884 conf.location = None;
885 let mut requests = 0;
886 let result = upload_large_file(
887 &conf,
888 prepared(),
889 987654321,
890 |_, _| {
891 requests += 1;
892 bail!("unexpected request")
893 },
894 |_, _| Ok(()),
895 );
896 assert_eq!(
897 result.err().unwrap().to_string(),
898 "Missing authenticated CDN location"
899 );
900 assert_eq!(requests, 0);
901 }
902
903 #[test]
904 fn rejects_conflicting_or_incomplete_block_acknowledgement() {
905 for bad in ["offset", "fin", "etag", "seq"] {
906 let mut peer = Peer::new(prepared(), 1024 * 1024);
907 let result = upload_large_file(
908 &config(),
909 prepared(),
910 987654321,
911 |path, fields| {
912 let mut reply = peer.respond(path, fields)?;
913 if path.ends_with("/uploadblock") {
914 if bad == "etag" {
915 reply.remove("etag");
916 } else {
917 put(&mut reply, bad, 1);
918 }
919 }
920 Ok(reply)
921 },
922 |_, _| Ok(()),
923 );
924 assert!(result.is_err(), "Accepted invalid {bad}");
925 assert_eq!(peer.requests, 2);
926 }
927 }
928
929 #[test]
930 fn cancellation_before_request_or_after_ack_never_continues() {
931 for cancel_at in [1, 2, 3, 4, 5, 6] {
932 let mut peer = Peer::new(prepared(), 1024 * 1024);
933 let mut callbacks = 0;
934 let result = upload_large_file(
935 &config(),
936 prepared(),
937 987654321,
938 |path, fields| peer.respond(path, fields),
939 |_, _| {
940 callbacks += 1;
941 ensure!(callbacks != cancel_at, "cancelled");
942 Ok(())
943 },
944 );
945 assert!(result.is_err());
946 assert_eq!(callbacks, cancel_at);
947 assert_eq!(peer.requests, cancel_at / 2);
948 }
949 }
950
951 #[test]
952 fn transport_failure_is_not_retried_and_missing_completion_is_not_success() {
953 let mut requests = 0;
954 assert!(upload_large_file(
955 &config(),
956 prepared(),
957 987654321,
958 |_, _| {
959 requests += 1;
960 bail!("transport failed")
961 },
962 |_, _| Ok(())
963 )
964 .is_err());
965 assert_eq!(requests, 1);
966 for phase in ["fin", "complete"] {
967 let mut peer = Peer::new(prepared(), 1024 * 1024);
968 let result = upload_large_file(
969 &config(),
970 prepared(),
971 987654321,
972 |path, fields| {
973 let mut reply = peer.respond(path, fields)?;
974 if path.ends_with(&format!("/{phase}")) {
975 reply.remove(if phase == "fin" { "upinfo" } else { "fileid" });
976 }
977 Ok(reply)
978 },
979 |_, _| Ok(()),
980 );
981 assert!(result.is_err());
982 assert_eq!(
983 peer.requests,
984 peer.blocks() + if phase == "fin" { 2 } else { 3 }
985 );
986 }
987 }
988
989 #[test]
990 fn invalid_persisted_material_and_task_are_rejected_before_network() {
991 let mut altered = prepared().clone();
992 altered.encrypted[0] ^= 1;
993 let small = PreparedUpload::with_material(
994 b"small control",
995 MediaQuality::File,
996 "small.bin",
997 "filehelper",
998 &[0x31; 16],
999 "00112233445566778899aabbccddeeff",
1000 )
1001 .unwrap();
1002 let mut requests = 0;
1003 for (prepared, task) in [(&altered, 987654321), (&small, 987654321), (prepared(), 0)] {
1004 assert!(upload_large_file(
1005 &config(),
1006 prepared,
1007 task,
1008 |_, _| {
1009 requests += 1;
1010 bail!("must not reach transport")
1011 },
1012 |_, _| Ok(())
1013 )
1014 .is_err());
1015 }
1016 assert_eq!(requests, 0);
1017 }
1018}