Skip to main content

wechat_protocol/
large_file.rs

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
7// The reference sends the current 128-block group and at most its two preceding
8// groups. This uploader is sequential: only blocks before `next_block` have a
9// verified acknowledgement. Offsets and alreadyupsize remain file-relative.
10fn 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    /// Upload one ordinary file using previously persisted source/key material.
22    /// Object readback and business-message submission remain separate operations.
23    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    /// Upload the unmodified original as a separate type-4 object. The normal
43    /// rendition and thumbnail use their own paired upload and final metadata.
44    /// Persist the task ID and prepared material before calling this method.
45    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    // No automatic retries: a failed request leaves the caller's persisted
190    // source/key material intact and never submits a business message.
191    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    // Return the correlated completion even if cancellation arrived while this
299    // request was in flight. The caller persists it before checking its fence.
300    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    // This peer accepts only the captured four-phase wire contract. It checks
415    // offsets, encrypted block hashes, prior acknowledgements and both tokens.
416    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}