1use anyhow::{Result, anyhow};
2use base64::Engine;
3use serde::Deserialize;
4use wacore::download::MediaType;
5use wacore::net::WHATSAPP_WEB_ORIGIN;
6use wacore::sync_marker::MaybeSend;
7
8use crate::client::Client;
9use crate::http::{HttpRequest, HttpResponse, HttpStatusError};
10use crate::mediaconn::{MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS, is_media_auth_error};
11
12const RESUMABLE_UPLOAD_THRESHOLD: usize = 5 * 1024 * 1024;
15
16enum UploadExistsResult {
18 Complete { url: String, direct_path: String },
20 Resume { byte_offset: u64 },
22 NotFound,
24}
25
26#[derive(Deserialize)]
28struct UploadProgressResponse {
29 #[serde(default)]
30 url: Option<String>,
31 #[serde(default)]
32 direct_path: Option<String>,
33 #[serde(default)]
35 resume: Option<String>,
36}
37
38fn parse_upload_progress(resp: &HttpResponse, total_size: u64) -> UploadExistsResult {
40 if resp.status_code >= 400 {
41 return UploadExistsResult::NotFound;
42 }
43 let Ok(progress) = serde_json::from_slice::<UploadProgressResponse>(&resp.body) else {
44 return UploadExistsResult::NotFound;
45 };
46 match progress.resume.as_deref() {
47 Some("complete") => {
48 if let (Some(url), Some(direct_path)) = (progress.url, progress.direct_path) {
49 UploadExistsResult::Complete { url, direct_path }
50 } else {
51 UploadExistsResult::NotFound
52 }
53 }
54 Some(offset_str) => match offset_str.parse::<u64>() {
55 Ok(offset) if offset > 0 && offset < total_size => UploadExistsResult::Resume {
56 byte_offset: offset,
57 },
58 _ => UploadExistsResult::NotFound,
59 },
60 _ => UploadExistsResult::NotFound,
61 }
62}
63
64fn build_upload_request(
67 hostname: &str,
68 upload_path: &str,
69 auth: &str,
70 token: &str,
71 file_offset: Option<u64>,
72) -> HttpRequest {
73 let mut url = format!("https://{hostname}{upload_path}/{token}?auth={auth}&token={token}");
74 if let Some(offset) = file_offset {
75 url.push_str("&file_offset=");
76 url.push_str(itoa::Buffer::new().format(offset));
77 }
78
79 HttpRequest::post(url)
80 .with_header("Content-Type", "application/octet-stream")
81 .with_header("Origin", WHATSAPP_WEB_ORIGIN)
82}
83
84fn build_resume_check_request(
85 hostname: &str,
86 upload_path: &str,
87 auth: &str,
88 token: &str,
89) -> HttpRequest {
90 let url = format!("https://{hostname}{upload_path}/{token}?auth={auth}&token={token}&resume=1");
91 HttpRequest::post(url).with_header("Origin", WHATSAPP_WEB_ORIGIN)
92}
93
94fn upload_error_from_response(response: HttpResponse) -> anyhow::Error {
95 let status = response.status_code;
96 let context = match response.body_string() {
97 Ok(body) => format!("Upload failed {status} body={body}"),
98 Err(body_err) => {
99 format!("Upload failed {status} and failed to read response body: {body_err}")
100 }
101 };
102 HttpStatusError { status }.into_error(context)
106}
107
108struct UploadCrypto {
111 media_key: [u8; 32],
112 file_sha256: [u8; 32],
113 file_enc_sha256: [u8; 32],
114 streaming_sidecar: Option<Vec<u8>>,
115}
116
117impl UploadCrypto {
118 fn response(
119 &self,
120 url: String,
121 direct_path: String,
122 file_length: u64,
123 media_key_timestamp: i64,
124 ) -> UploadResponse {
125 UploadResponse {
126 url,
127 direct_path,
128 media_key: self.media_key,
129 file_enc_sha256: self.file_enc_sha256,
130 file_sha256: self.file_sha256,
131 file_length,
132 media_key_timestamp,
133 streaming_sidecar: self.streaming_sidecar.clone(),
134 }
135 }
136}
137
138#[cfg(not(target_arch = "wasm32"))]
142type BoxFut<'a, T> = std::pin::Pin<Box<dyn Future<Output = T> + Send + 'a>>;
143#[cfg(target_arch = "wasm32")]
144type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>;
145
146#[cfg(not(target_arch = "wasm32"))]
149mod dyn_callbacks {
150 use super::*;
151 pub(super) type GetMediaConnDyn<'a> =
152 dyn FnMut(bool) -> BoxFut<'a, Result<crate::mediaconn::MediaConn>> + Send + 'a;
153 pub(super) type InvalidateMediaConnDyn<'a> = dyn FnMut() -> BoxFut<'a, ()> + Send + 'a;
154 pub(super) type ExecuteRequestDyn<'a> =
155 dyn FnMut(HttpRequest) -> BoxFut<'a, Result<HttpResponse>> + Send + 'a;
156 pub(super) type SendBodyDyn<'a> =
157 dyn FnMut(HttpRequest, u64, u64) -> BoxFut<'a, Result<HttpResponse>> + Send + 'a;
158}
159#[cfg(target_arch = "wasm32")]
160mod dyn_callbacks {
161 use super::*;
162 pub(super) type GetMediaConnDyn<'a> =
163 dyn FnMut(bool) -> BoxFut<'a, Result<crate::mediaconn::MediaConn>> + 'a;
164 pub(super) type InvalidateMediaConnDyn<'a> = dyn FnMut() -> BoxFut<'a, ()> + 'a;
165 pub(super) type ExecuteRequestDyn<'a> =
166 dyn FnMut(HttpRequest) -> BoxFut<'a, Result<HttpResponse>> + 'a;
167 pub(super) type SendBodyDyn<'a> =
168 dyn FnMut(HttpRequest, u64, u64) -> BoxFut<'a, Result<HttpResponse>> + 'a;
169}
170use dyn_callbacks::*;
171
172#[allow(clippy::too_many_arguments)]
180async fn upload_media_with_retry<GMC, GMCFut, IMC, IMCFut, EXR, EXRFut, SB, SBFut>(
181 crypto: UploadCrypto,
182 media_type: MediaType,
183 file_length: u64,
184 ciphertext_len: u64,
185 media_key_timestamp: i64,
186 mut get_media_conn: GMC,
187 mut invalidate_media_conn: IMC,
188 mut execute_request: EXR,
189 mut send_body: SB,
190) -> Result<UploadResponse>
191where
192 GMC: FnMut(bool) -> GMCFut + MaybeSend,
193 GMCFut: Future<Output = Result<crate::mediaconn::MediaConn>> + MaybeSend,
194 IMC: FnMut() -> IMCFut + MaybeSend,
195 IMCFut: Future<Output = ()> + MaybeSend,
196 EXR: FnMut(HttpRequest) -> EXRFut + MaybeSend,
197 EXRFut: Future<Output = Result<HttpResponse>> + MaybeSend,
198 SB: FnMut(HttpRequest, u64, u64) -> SBFut + MaybeSend,
199 SBFut: Future<Output = Result<HttpResponse>> + MaybeSend,
200{
201 upload_media_with_retry_dyn(
202 crypto,
203 media_type,
204 file_length,
205 ciphertext_len,
206 media_key_timestamp,
207 &mut |force| Box::pin(get_media_conn(force)),
208 &mut || Box::pin(invalidate_media_conn()),
209 &mut |request| Box::pin(execute_request(request)),
210 &mut |request, offset, remaining| Box::pin(send_body(request, offset, remaining)),
211 )
212 .await
213}
214
215#[allow(clippy::too_many_arguments)]
216async fn upload_media_with_retry_dyn<'a>(
217 crypto: UploadCrypto,
218 media_type: MediaType,
219 file_length: u64,
220 ciphertext_len: u64,
221 media_key_timestamp: i64,
222 get_media_conn: &mut GetMediaConnDyn<'a>,
223 invalidate_media_conn: &mut InvalidateMediaConnDyn<'a>,
224 execute_request: &mut ExecuteRequestDyn<'a>,
225 send_body: &mut SendBodyDyn<'a>,
226) -> Result<UploadResponse> {
227 let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(crypto.file_enc_sha256);
228 let upload_path = media_type.upload_path();
229 let mut force_refresh = false;
230 let mut last_error: Option<anyhow::Error> = None;
231
232 for attempt in 0..=MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS {
233 let media_conn = get_media_conn(force_refresh).await?;
234 if media_conn.hosts.is_empty() {
235 return Err(anyhow!("No media hosts"));
236 }
237
238 let mut retry_with_fresh_auth = false;
239
240 for host in &media_conn.hosts {
241 let mut offset: u64 = 0;
242 let mut file_offset: Option<u64> = None;
243
244 if ciphertext_len >= RESUMABLE_UPLOAD_THRESHOLD as u64 {
247 let check_req = build_resume_check_request(
248 &host.hostname,
249 upload_path,
250 &media_conn.auth,
251 &token,
252 );
253 if let Ok(check_resp) = execute_request(check_req).await {
254 match parse_upload_progress(&check_resp, ciphertext_len) {
255 UploadExistsResult::Complete { url, direct_path } => {
256 return Ok(crypto.response(
257 url,
258 direct_path,
259 file_length,
260 media_key_timestamp,
261 ));
262 }
263 UploadExistsResult::Resume { byte_offset } => {
264 if byte_offset >= ciphertext_len {
265 log::warn!(
266 "Server resume offset {byte_offset} exceeds data length {ciphertext_len}; uploading from start"
267 );
268 } else {
269 log::info!(
270 "Resuming upload from byte {byte_offset}/{ciphertext_len}"
271 );
272 offset = byte_offset;
273 file_offset = Some(byte_offset);
274 }
275 }
276 UploadExistsResult::NotFound => {}
277 }
278 }
279 }
281
282 let request = build_upload_request(
283 &host.hostname,
284 upload_path,
285 &media_conn.auth,
286 &token,
287 file_offset,
288 );
289
290 let response = match send_body(request, offset, ciphertext_len - offset).await {
291 Ok(response) => response,
292 Err(err) => {
293 last_error = Some(err);
294 continue;
295 }
296 };
297
298 if response.status_code < 400 {
299 let raw: RawUploadResponse = serde_json::from_slice(&response.body)?;
300 return Ok(crypto.response(
301 raw.url,
302 raw.direct_path,
303 file_length,
304 media_key_timestamp,
305 ));
306 }
307
308 let status_code = response.status_code;
309 let err = upload_error_from_response(response);
310
311 if is_media_auth_error(status_code) {
312 if attempt == 0 {
313 invalidate_media_conn().await;
314 force_refresh = true;
315 retry_with_fresh_auth = true;
316 break;
317 }
318
319 return Err(err);
320 }
321
322 last_error = Some(err);
323 }
324
325 if !retry_with_fresh_auth {
326 break;
327 }
328 }
329
330 Err(last_error.unwrap_or_else(|| anyhow!("Failed to upload to all available media hosts")))
331}
332
333#[derive(Debug, Clone)]
334#[non_exhaustive]
335pub struct UploadResponse {
336 pub url: String,
337 pub direct_path: String,
338 pub media_key: [u8; 32],
339 pub file_enc_sha256: [u8; 32],
340 pub file_sha256: [u8; 32],
341 pub file_length: u64,
342 pub media_key_timestamp: i64,
344 pub streaming_sidecar: Option<Vec<u8>>,
347}
348
349impl From<UploadResponse> for wacore::sticker_pack::MediaUploadInfo {
350 fn from(r: UploadResponse) -> Self {
351 Self::new(
352 r.direct_path,
353 r.media_key,
354 r.file_sha256,
355 r.file_enc_sha256,
356 r.file_length,
357 r.media_key_timestamp,
358 )
359 }
360}
361
362#[derive(Deserialize)]
363struct RawUploadResponse {
364 url: String,
365 direct_path: String,
366}
367
368#[non_exhaustive]
369#[derive(Default, Clone)]
370pub struct UploadOptions {
371 pub media_key: Option<[u8; 32]>,
373 pub streaming_sidecar: Option<bool>,
376}
377
378impl std::fmt::Debug for UploadOptions {
379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380 f.debug_struct("UploadOptions")
381 .field("media_key", &self.media_key.as_ref().map(|_| "<redacted>"))
382 .field("streaming_sidecar", &self.streaming_sidecar)
383 .finish()
384 }
385}
386
387impl UploadOptions {
388 pub fn new() -> Self {
389 Self::default()
390 }
391
392 pub fn with_media_key(mut self, key: [u8; 32]) -> Self {
393 self.media_key = Some(key);
394 self
395 }
396
397 pub fn with_streaming_sidecar(mut self, enabled: bool) -> Self {
398 self.streaming_sidecar = Some(enabled);
399 self
400 }
401}
402
403impl Client {
404 #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.upload", level = "debug", skip_all, fields(kind = ?media_type, len = data.len()), err(Debug)))]
409 pub async fn upload(
410 &self,
411 data: Vec<u8>,
412 media_type: MediaType,
413 options: UploadOptions,
414 ) -> Result<UploadResponse> {
415 let file_length = data.len() as u64;
416 let media_key = options.media_key;
417 let sidecar = options.streaming_sidecar;
418 let enc = wacore::runtime::blocking(&*self.runtime, move || {
419 wacore::upload::encrypt_media_with_key_and_sidecar(
420 &data,
421 media_type,
422 media_key.as_ref(),
423 sidecar,
424 )
425 })
426 .await?;
427
428 let ciphertext_len = enc.data_to_upload.len() as u64;
429 let crypto = UploadCrypto {
430 media_key: enc.media_key,
431 file_sha256: enc.file_sha256,
432 file_enc_sha256: enc.file_enc_sha256,
433 streaming_sidecar: enc.streaming_sidecar,
434 };
435 let ciphertext = bytes::Bytes::from(enc.data_to_upload);
438
439 upload_media_with_retry(
440 crypto,
441 media_type,
442 file_length,
443 ciphertext_len,
444 wacore::time::now_secs(),
445 |force| async move { self.refresh_media_conn(force).await.map_err(Into::into) },
446 || async { self.invalidate_media_conn().await },
447 |request| async move { self.http_client.execute(request).await },
448 |request, offset, _remaining| {
449 let body = ciphertext.slice(offset as usize..);
450 async move { self.http_client.execute(request.with_body(body)).await }
451 },
452 )
453 .await
454 }
455
456 #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.upload_stream", level = "debug", skip_all, fields(kind = ?media_type), err(Debug)))]
463 pub async fn upload_stream<S>(
464 &self,
465 source: S,
466 info: wacore::upload::EncryptedMediaInfo,
467 media_type: MediaType,
468 ) -> Result<UploadResponse>
469 where
470 S: wacore::upload::UploadSource + 'static,
471 {
472 let file_length = info.file_length;
473 let ciphertext_len = source.len();
474 let crypto = UploadCrypto {
475 media_key: info.media_key,
476 file_sha256: info.file_sha256,
477 file_enc_sha256: info.file_enc_sha256,
478 streaming_sidecar: info.streaming_sidecar,
479 };
480 let source = std::sync::Arc::new(source);
481
482 upload_media_with_retry(
483 crypto,
484 media_type,
485 file_length,
486 ciphertext_len,
487 wacore::time::now_secs(),
488 |force| async move { self.refresh_media_conn(force).await.map_err(Into::into) },
489 || async { self.invalidate_media_conn().await },
490 |request| async move { self.http_client.execute(request).await },
491 |request, offset, remaining| {
492 let source = std::sync::Arc::clone(&source);
493 let http = std::sync::Arc::clone(&self.http_client);
494 async move {
495 wacore::runtime::blocking(&*self.runtime, move || {
498 let reader = source.reader_from(offset)?;
499 http.execute_upload(request, reader, remaining)
500 })
501 .await
502 }
503 },
504 )
505 .await
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::mediaconn::{MediaConn, MediaConnHost};
513 use async_lock::Mutex;
514 use std::sync::Arc;
515 use wacore::time::Instant;
516
517 fn media_conn(auth: &str, hosts: &[&str]) -> MediaConn {
518 MediaConn {
519 auth: auth.to_string(),
520 ttl: 60,
521 auth_ttl: None,
522 hosts: hosts
523 .iter()
524 .map(|hostname| MediaConnHost::new((*hostname).to_string()))
525 .collect(),
526 fetched_at: Instant::now(),
527 }
528 }
529
530 fn crypto_from(enc: &wacore::upload::EncryptedMedia) -> UploadCrypto {
531 UploadCrypto {
532 media_key: enc.media_key,
533 file_sha256: enc.file_sha256,
534 file_enc_sha256: enc.file_enc_sha256,
535 streaming_sidecar: enc.streaming_sidecar.clone(),
536 }
537 }
538
539 fn ok_json(url: &str, direct_path: &str) -> HttpResponse {
540 HttpResponse {
541 status_code: 200,
542 body: format!(r#"{{"url":"{url}","direct_path":"{direct_path}"}}"#).into_bytes(),
543 }
544 }
545
546 async fn unreachable_check(_req: HttpRequest) -> Result<HttpResponse> {
549 panic!("resume check must not run below the resumable threshold")
550 }
551
552 #[tokio::test]
553 async fn upload_retries_with_forced_media_conn_refresh_after_auth_error() {
554 let enc = wacore::upload::encrypt_media(b"retry me", MediaType::Image)
555 .expect("encryption should succeed");
556 let len = enc.data_to_upload.len() as u64;
557 let first_conn = media_conn("stale-auth", &["cdn1.example.com"]);
558 let refreshed_conn = media_conn("fresh-auth", &["cdn2.example.com"]);
559 let refresh_calls = Arc::new(Mutex::new(Vec::new()));
560 let invalidations = Arc::new(Mutex::new(0usize));
561 let seen_urls = Arc::new(Mutex::new(Vec::new()));
562
563 let result = upload_media_with_retry(
564 crypto_from(&enc),
565 MediaType::Image,
566 8,
567 len,
568 0,
569 {
570 let refresh_calls = Arc::clone(&refresh_calls);
571 move |force| {
572 let refresh_calls = Arc::clone(&refresh_calls);
573 let first_conn = first_conn.clone();
574 let refreshed_conn = refreshed_conn.clone();
575 async move {
576 refresh_calls.lock().await.push(force);
577 Ok(if force { refreshed_conn } else { first_conn })
578 }
579 }
580 },
581 {
582 let invalidations = Arc::clone(&invalidations);
583 move || {
584 let invalidations = Arc::clone(&invalidations);
585 async move {
586 *invalidations.lock().await += 1;
587 }
588 }
589 },
590 unreachable_check,
591 {
592 let seen_urls = Arc::clone(&seen_urls);
593 move |request: HttpRequest, _offset, _remaining| {
594 let seen_urls = Arc::clone(&seen_urls);
595 async move {
596 seen_urls.lock().await.push(request.url.clone());
597 if request.url.contains("stale-auth") {
598 Ok(HttpResponse {
599 status_code: 401,
600 body: b"expired".to_vec(),
601 })
602 } else {
603 Ok(ok_json(
604 "https://cdn2.example.com/file",
605 "/v/t62.7118-24/123",
606 ))
607 }
608 }
609 }
610 },
611 )
612 .await
613 .expect("upload should succeed after refreshing media auth");
614
615 assert_eq!(*refresh_calls.lock().await, vec![false, true]);
616 assert_eq!(*invalidations.lock().await, 1);
617
618 let seen_urls = seen_urls.lock().await.clone();
619 assert_eq!(seen_urls.len(), 2);
620 assert!(seen_urls[0].contains("cdn1.example.com"));
621 assert!(seen_urls[0].contains("auth=stale-auth"));
622 assert!(seen_urls[1].contains("cdn2.example.com"));
623 assert!(seen_urls[1].contains("auth=fresh-auth"));
624 assert_eq!(result.direct_path, "/v/t62.7118-24/123");
625 assert_eq!(result.url, "https://cdn2.example.com/file");
626 assert_eq!(result.media_key_timestamp, 0);
627 }
628
629 #[tokio::test]
630 async fn upload_fails_over_to_next_host_after_non_auth_error() {
631 let enc = wacore::upload::encrypt_media(b"retry host", MediaType::Image)
632 .expect("encryption should succeed");
633 let len = enc.data_to_upload.len() as u64;
634 let conn = media_conn("shared-auth", &["cdn1.example.com", "cdn2.example.com"]);
635 let seen_urls = Arc::new(Mutex::new(Vec::new()));
636
637 let result = upload_media_with_retry(
638 crypto_from(&enc),
639 MediaType::Image,
640 10,
641 len,
642 0,
643 move |_force| {
644 let conn = conn.clone();
645 async move { Ok(conn) }
646 },
647 || async {},
648 unreachable_check,
649 {
650 let seen_urls = Arc::clone(&seen_urls);
651 move |request: HttpRequest, _offset, _remaining| {
652 let seen_urls = Arc::clone(&seen_urls);
653 async move {
654 seen_urls.lock().await.push(request.url.clone());
655 if request.url.contains("cdn1.example.com") {
656 Ok(HttpResponse {
657 status_code: 500,
658 body: b"try another host".to_vec(),
659 })
660 } else {
661 Ok(ok_json(
662 "https://cdn2.example.com/file",
663 "/v/t62.7118-24/456",
664 ))
665 }
666 }
667 }
668 },
669 )
670 .await
671 .expect("upload should succeed on the second host");
672
673 let seen_urls = seen_urls.lock().await.clone();
674 assert_eq!(seen_urls.len(), 2);
675 assert!(seen_urls[0].contains("cdn1.example.com"));
676 assert!(seen_urls[1].contains("cdn2.example.com"));
677 assert_eq!(result.direct_path, "/v/t62.7118-24/456");
678 assert_eq!(result.media_key_timestamp, 0);
679 }
680
681 #[tokio::test]
689 async fn the_upload_status_survives_to_the_public_error() {
690 use crate::error::ErrorChainExt;
691
692 let enc = wacore::upload::encrypt_media(b"nowhere to go", MediaType::Image)
693 .expect("encryption should succeed");
694 let len = enc.data_to_upload.len() as u64;
695 let conn = media_conn("shared-auth", &["cdn1.example.com", "cdn2.example.com"]);
696
697 let err = upload_media_with_retry(
698 crypto_from(&enc),
699 MediaType::Image,
700 10,
701 len,
702 0,
703 move |_force| {
704 let conn = conn.clone();
705 async move { Ok(conn) }
706 },
707 || async {},
708 unreachable_check,
709 move |_request: HttpRequest, _offset, _remaining| async move {
710 Ok(HttpResponse {
711 status_code: 507,
712 body: b"insufficient storage".to_vec(),
713 })
714 },
715 )
716 .await
717 .expect_err("every host refusing must fail the upload");
718
719 let cause: &(dyn std::error::Error + 'static) = err.as_ref();
720 assert_eq!(
721 cause.http_status(),
722 Some(507),
723 "the consumer must recover the host's status by type, got: {err:?}"
724 );
725 assert!(
726 format!("{err}").contains("507"),
727 "the message should still name the status, got: {err}"
728 );
729 }
730
731 #[tokio::test]
734 async fn resumable_upload_sends_tail_from_offset() {
735 let total: u64 = 6 * 1024 * 1024;
736 let offset: u64 = 1024 * 1024;
737 let conn = media_conn("auth", &["cdn1.example.com"]);
738 let captured: Arc<Mutex<Option<(String, u64, u64)>>> = Arc::new(Mutex::new(None));
739
740 let crypto = UploadCrypto {
741 media_key: [0u8; 32],
742 file_sha256: [0u8; 32],
743 file_enc_sha256: [9u8; 32],
744 streaming_sidecar: Some(vec![1, 2, 3]),
745 };
746
747 let result = upload_media_with_retry(
748 crypto,
749 MediaType::Video,
750 total,
751 total,
752 0,
753 move |_force| {
754 let conn = conn.clone();
755 async move { Ok(conn) }
756 },
757 || async {},
758 move |_req| async move {
759 Ok(HttpResponse {
760 status_code: 200,
761 body: format!(r#"{{"resume":"{offset}"}}"#).into_bytes(),
762 })
763 },
764 {
765 let captured = Arc::clone(&captured);
766 move |request: HttpRequest, off, remaining| {
767 let captured = Arc::clone(&captured);
768 async move {
769 *captured.lock().await = Some((request.url.clone(), off, remaining));
770 Ok(ok_json("https://cdn1.example.com/f", "/dp"))
771 }
772 }
773 },
774 )
775 .await
776 .expect("resumable upload should succeed");
777
778 let (url, sent_offset, remaining) = captured.lock().await.clone().unwrap();
779 assert_eq!(sent_offset, offset);
780 assert_eq!(remaining, total - offset);
781 assert!(url.contains(&format!("file_offset={offset}")), "url: {url}");
782 assert_eq!(result.streaming_sidecar.as_deref(), Some(&[1u8, 2, 3][..]));
784 }
785
786 #[test]
789 fn arc_upload_source_reads_from_offset_repeatably() {
790 use std::io::Read;
791 use wacore::upload::UploadSource;
792
793 let bytes: Arc<[u8]> = (0u8..200).collect::<Vec<_>>().into();
794 assert_eq!(UploadSource::len(&bytes), 200);
795
796 let read_at = |offset: u64| {
797 let mut r = bytes.reader_from(offset).unwrap();
798 let mut out = Vec::new();
799 r.read_to_end(&mut out).unwrap();
800 out
801 };
802
803 assert_eq!(read_at(0), (0u8..200).collect::<Vec<_>>());
804 assert_eq!(read_at(50), (50u8..200).collect::<Vec<_>>());
805 assert_eq!(read_at(50), read_at(50));
807 assert_eq!(read_at(200), Vec::<u8>::new());
808 }
809}