1use crate::client::Client;
2use crate::http::{
3 HTTP_STATUS_GONE, HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK, HTTP_STATUS_REDIRECTION_START,
4 HttpClient, HttpStatusError,
5};
6use crate::mediaconn::{MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS, MediaConn, is_media_auth_error};
7use anyhow::{Result, anyhow};
8use std::sync::Arc;
9use wacore::runtime::Runtime;
10
11pub use wacore::download::{
12 DEFAULT_MEDIA_HOSTS, DownloadUtils, DownloadWriter, Downloadable, MediaDecryption,
13 MediaDecryptionError, MediaHost, MediaRoute, MediaType,
14};
15
16const DOWNLOAD_PREALLOC_CAP: u64 = 64 * 1024 * 1024;
22
23impl From<&MediaConn> for MediaRoute {
24 fn from(conn: &MediaConn) -> Self {
25 MediaRoute::authenticated(
26 conn.hosts
27 .iter()
28 .map(|h| MediaHost::new(h.hostname.clone()))
29 .collect(),
30 conn.auth.clone(),
31 )
32 }
33}
34
35pub struct DownloadParams {
38 pub direct_path: String,
39 pub media_key: Option<Vec<u8>>,
40 pub file_sha256: Vec<u8>,
41 pub file_enc_sha256: Option<Vec<u8>>,
42 pub file_length: u64,
43 pub media_type: MediaType,
44}
45
46impl DownloadParams {
47 pub fn encrypted(
49 direct_path: impl Into<String>,
50 media_key: &[u8],
51 file_sha256: &[u8],
52 file_enc_sha256: &[u8],
53 file_length: u64,
54 media_type: MediaType,
55 ) -> Self {
56 Self {
57 direct_path: direct_path.into(),
58 media_key: Some(media_key.to_vec()),
59 file_sha256: file_sha256.to_vec(),
60 file_enc_sha256: Some(file_enc_sha256.to_vec()),
61 file_length,
62 media_type,
63 }
64 }
65}
66
67impl Downloadable for DownloadParams {
68 fn direct_path(&self) -> Option<&str> {
69 Some(&self.direct_path)
70 }
71 fn media_key(&self) -> Option<&[u8]> {
72 self.media_key.as_deref()
73 }
74 fn file_enc_sha256(&self) -> Option<&[u8]> {
75 self.file_enc_sha256.as_deref()
76 }
77 fn file_sha256(&self) -> Option<&[u8]> {
78 Some(&self.file_sha256)
79 }
80 fn file_length(&self) -> Option<u64> {
81 Some(self.file_length)
82 }
83 fn app_info(&self) -> MediaType {
84 self.media_type
85 }
86}
87
88#[derive(Debug, thiserror::Error)]
93#[non_exhaustive]
94pub enum MediaDownloadError {
95 #[error("the CDN rejected the media reference: {0}")]
98 ReferenceRejected(#[source] anyhow::Error),
99 #[error("every media host failed: {0}")]
102 HostsUnreachable(#[source] anyhow::Error),
103 #[error("the media route names no hosts")]
105 NoHosts,
106 #[error("{0}")]
109 Other(#[from] anyhow::Error),
110}
111
112impl From<DownloadRequestError> for MediaDownloadError {
113 fn from(err: DownloadRequestError) -> Self {
114 match err {
115 DownloadRequestError::Auth(e) | DownloadRequestError::NotFound(e) => {
116 Self::ReferenceRejected(e)
117 }
118 DownloadRequestError::Other(e) => Self::HostsUnreachable(e),
119 DownloadRequestError::Prepare(e) => Self::Other(e),
120 DownloadRequestError::NoHosts => Self::NoHosts,
121 }
122 }
123}
124
125#[derive(Debug)]
126enum DownloadRequestError {
127 Auth(anyhow::Error),
128 NotFound(anyhow::Error),
131 Other(anyhow::Error),
132 Prepare(anyhow::Error),
135 NoHosts,
137}
138
139impl DownloadRequestError {
140 fn auth(status_code: u16) -> Self {
141 Self::Auth(Self::refused(
142 status_code,
143 format!("Download failed with status: {status_code}"),
144 ))
145 }
146
147 fn not_found(status_code: u16) -> Self {
148 Self::NotFound(Self::refused(
149 status_code,
150 format!("Download media not found/expired with status: {status_code}"),
151 ))
152 }
153
154 fn refused_status(status_code: u16) -> Self {
158 Self::Other(Self::refused(
159 status_code,
160 format!("Download failed with status: {status_code}"),
161 ))
162 }
163
164 fn refused(status_code: u16, context: String) -> anyhow::Error {
170 HttpStatusError {
171 status: status_code,
172 }
173 .into_error(context)
174 }
175
176 fn other(err: impl Into<anyhow::Error>) -> Self {
180 Self::Other(err.into())
181 }
182
183 fn is_auth(&self) -> bool {
184 matches!(self, Self::Auth(_))
185 }
186
187 fn is_not_found(&self) -> bool {
189 matches!(self, Self::NotFound(_))
190 }
191
192 fn into_anyhow(self) -> anyhow::Error {
193 match self {
194 Self::Auth(err) | Self::NotFound(err) | Self::Other(err) | Self::Prepare(err) => err,
195 Self::NoHosts => anyhow!("Failed to download from all available media hosts"),
196 }
197 }
198}
199
200fn validate_download_status(status_code: u16) -> std::result::Result<(), DownloadRequestError> {
201 if status_code < HTTP_STATUS_REDIRECTION_START {
202 return Ok(());
203 }
204
205 let error = if is_media_auth_error(status_code) {
206 DownloadRequestError::auth(status_code)
207 } else if matches!(status_code, HTTP_STATUS_NOT_FOUND | HTTP_STATUS_GONE) {
208 DownloadRequestError::not_found(status_code)
209 } else {
210 DownloadRequestError::refused_status(status_code)
211 };
212 Err(error)
213}
214
215fn decrypt_or_validate_buffered_body(
216 body: &mut Vec<u8>,
217 decryption: &MediaDecryption,
218) -> std::result::Result<(), DownloadRequestError> {
219 match decryption {
220 MediaDecryption::Encrypted {
221 media_key,
222 media_type,
223 } => DownloadUtils::verify_and_decrypt_in_place(body, media_key, *media_type)
224 .map_err(DownloadRequestError::other),
225 MediaDecryption::Plaintext { file_sha256 } => {
226 DownloadUtils::validate_plaintext_sha256(body, file_sha256)
227 .map_err(DownloadRequestError::other)
228 }
229 }
230}
231
232async fn download_media_with_retry<
241 PrepareRequests,
242 PrepareRequestsFut,
243 InvalidateMediaConn,
244 InvalidateMediaConnFut,
245 ExecuteRequest,
246 ExecuteRequestFut,
247>(
248 max_refresh_attempts: usize,
249 mut prepare_requests: PrepareRequests,
250 mut invalidate_media_conn: InvalidateMediaConn,
251 mut execute_request: ExecuteRequest,
252) -> std::result::Result<Vec<u8>, DownloadRequestError>
253where
254 PrepareRequests: FnMut(bool) -> PrepareRequestsFut,
255 PrepareRequestsFut: Future<Output = Result<Vec<wacore::download::DownloadRequest>>>,
256 InvalidateMediaConn: FnMut() -> InvalidateMediaConnFut,
257 InvalidateMediaConnFut: Future<Output = ()>,
258 ExecuteRequest: FnMut(wacore::download::DownloadRequest) -> ExecuteRequestFut,
259 ExecuteRequestFut: Future<Output = std::result::Result<Vec<u8>, DownloadRequestError>>,
260{
261 let mut force_refresh = false;
262 let mut last_err: Option<anyhow::Error> = None;
263
264 for attempt in 0..=max_refresh_attempts {
265 let requests = prepare_requests(force_refresh)
266 .await
267 .map_err(DownloadRequestError::Prepare)?;
268 let mut retry_with_fresh_auth = false;
269
270 for request in requests {
271 match execute_request(request.clone()).await {
272 Ok(data) => return Ok(data),
273 Err(err)
274 if (err.is_auth() || err.is_not_found()) && attempt < max_refresh_attempts =>
275 {
276 invalidate_media_conn().await;
278 force_refresh = true;
279 retry_with_fresh_auth = true;
280 break;
281 }
282 Err(err) if err.is_auth() || err.is_not_found() => return Err(err),
283 Err(err) => {
284 let err = err.into_anyhow();
285 log::warn!(
286 "Failed to download from URL {}: {:?}. Trying next host.",
287 request.url,
288 err
289 );
290 last_err = Some(err);
291 }
292 }
293 }
294
295 if !retry_with_fresh_auth {
296 break;
297 }
298 }
299
300 match last_err {
301 Some(err) => Err(DownloadRequestError::Other(err)),
302 None => Err(DownloadRequestError::NoHosts),
303 }
304}
305
306async fn download_to_writer_with_retry<
307 W,
308 PrepareRequests,
309 PrepareRequestsFut,
310 InvalidateMediaConn,
311 InvalidateMediaConnFut,
312 ExecuteRequest,
313 ExecuteRequestFut,
314>(
315 max_refresh_attempts: usize,
316 runtime: &Arc<dyn Runtime>,
317 mut writer: W,
318 mut prepare_requests: PrepareRequests,
319 mut invalidate_media_conn: InvalidateMediaConn,
320 mut execute_request: ExecuteRequest,
321) -> std::result::Result<W, DownloadRequestError>
322where
323 W: DownloadWriter + Send + 'static,
324 PrepareRequests: FnMut(bool) -> PrepareRequestsFut,
325 PrepareRequestsFut: Future<Output = Result<Vec<wacore::download::DownloadRequest>>>,
326 InvalidateMediaConn: FnMut() -> InvalidateMediaConnFut,
327 InvalidateMediaConnFut: Future<Output = ()>,
328 ExecuteRequest: FnMut(wacore::download::DownloadRequest, W) -> ExecuteRequestFut,
329 ExecuteRequestFut: Future<Output = Result<(W, std::result::Result<(), DownloadRequestError>)>>,
330{
331 let mut force_refresh = false;
332 let mut last_err: Option<anyhow::Error> = None;
333
334 for attempt in 0..=max_refresh_attempts {
335 let requests = match prepare_requests(force_refresh).await {
336 Ok(requests) => requests,
337 Err(err) => {
338 discard_failed_write(runtime, writer).await;
339 return Err(DownloadRequestError::Prepare(err));
340 }
341 };
342 let mut retry_with_fresh_auth = false;
343
344 for request in requests {
345 let (next_writer, result) = match execute_request(request.clone(), writer).await {
346 Ok(outcome) => outcome,
347 Err(err) => return Err(DownloadRequestError::Other(err)),
350 };
351 writer = next_writer;
352
353 match result {
354 Ok(()) => return Ok(writer),
355 Err(err)
356 if (err.is_auth() || err.is_not_found()) && attempt < max_refresh_attempts =>
357 {
358 invalidate_media_conn().await;
359 force_refresh = true;
360 retry_with_fresh_auth = true;
361 break;
362 }
363 Err(err) if err.is_auth() || err.is_not_found() => {
364 discard_failed_write(runtime, writer).await;
365 return Err(err);
366 }
367 Err(err) => {
368 let err = err.into_anyhow();
369 log::warn!(
370 "Failed to stream-download from URL {}: {:?}. Trying next host.",
371 request.url,
372 err
373 );
374 last_err = Some(err);
375 }
376 }
377 }
378
379 if !retry_with_fresh_auth {
380 break;
381 }
382 }
383
384 discard_failed_write(runtime, writer).await;
385 match last_err {
386 Some(err) => Err(DownloadRequestError::Other(err)),
387 None => Err(DownloadRequestError::NoHosts),
388 }
389}
390
391async fn execute_request_into_memory(
394 http_client: &Arc<dyn HttpClient>,
395 runtime: &Arc<dyn Runtime>,
396 request: &wacore::download::DownloadRequest,
397 capacity: usize,
398) -> std::result::Result<Vec<u8>, DownloadRequestError> {
399 if http_client.supports_streaming() {
400 let writer = std::io::Cursor::new(Vec::with_capacity(capacity));
401 match streaming_download_and_decrypt(http_client, runtime, request, writer).await {
402 Ok((writer, Ok(()))) => Ok(writer.into_inner()),
403 Ok((_, Err(e))) => Err(e),
404 Err(e) => Err(DownloadRequestError::other(e)),
405 }
406 } else {
407 buffered_download_to_vec(http_client, runtime, request).await
408 }
409}
410
411fn download_capacity(downloadable: &dyn Downloadable) -> usize {
414 downloadable
415 .file_length()
416 .unwrap_or(0)
417 .min(DOWNLOAD_PREALLOC_CAP) as usize
418}
419
420pub struct MediaDownloader {
428 http_client: Arc<dyn HttpClient>,
429 runtime: Arc<dyn Runtime>,
430 route: MediaRoute,
431}
432
433const NO_MEDIA_CONN_REFRESH: usize = 0;
437
438impl MediaDownloader {
439 pub fn new(
440 http_client: Arc<dyn HttpClient>,
441 runtime: Arc<dyn Runtime>,
442 route: MediaRoute,
443 ) -> Self {
444 Self {
445 http_client,
446 runtime,
447 route,
448 }
449 }
450
451 pub fn with_default_hosts(http_client: Arc<dyn HttpClient>, runtime: Arc<dyn Runtime>) -> Self {
453 Self::new(http_client, runtime, MediaRoute::default_hosts())
454 }
455
456 pub fn route(&self) -> &MediaRoute {
457 &self.route
458 }
459
460 #[cfg_attr(
463 feature = "tracing",
464 tracing::instrument(
465 name = "wa.media.download_via_route",
466 level = "debug",
467 skip_all,
468 err(Debug)
469 )
470 )]
471 pub async fn download(
472 &self,
473 downloadable: &dyn Downloadable,
474 ) -> std::result::Result<Vec<u8>, MediaDownloadError> {
475 let capacity = download_capacity(downloadable);
476 download_media_with_retry(
477 NO_MEDIA_CONN_REFRESH,
478 |_force| async { DownloadUtils::prepare_download_requests(downloadable, &self.route) },
479 || async {},
480 |request| async move {
481 execute_request_into_memory(&self.http_client, &self.runtime, &request, capacity)
482 .await
483 },
484 )
485 .await
486 .map_err(MediaDownloadError::from)
487 }
488
489 #[cfg_attr(
492 feature = "tracing",
493 tracing::instrument(
494 name = "wa.media.download_via_route_to_writer",
495 level = "debug",
496 skip_all,
497 err(Debug)
498 )
499 )]
500 pub async fn download_to_writer<W: DownloadWriter + Send + 'static>(
501 &self,
502 downloadable: &dyn Downloadable,
503 writer: W,
504 ) -> std::result::Result<W, MediaDownloadError> {
505 download_to_writer_with_retry(
506 NO_MEDIA_CONN_REFRESH,
507 &self.runtime,
508 writer,
509 |_force| async { DownloadUtils::prepare_download_requests(downloadable, &self.route) },
510 || async {},
511 |request, writer| async move {
512 streaming_download_and_decrypt(&self.http_client, &self.runtime, &request, writer)
513 .await
514 },
515 )
516 .await
517 .map_err(MediaDownloadError::from)
518 }
519}
520
521impl Client {
522 #[cfg_attr(
528 feature = "tracing",
529 tracing::instrument(name = "wa.media.download", level = "debug", skip_all, err(Debug))
530 )]
531 pub async fn download(&self, downloadable: &dyn Downloadable) -> Result<Vec<u8>> {
532 let capacity = download_capacity(downloadable);
538 download_media_with_retry(
539 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
540 |force| self.prepare_requests(downloadable, force),
541 || async { self.invalidate_media_conn().await },
542 |request| async move {
543 execute_request_into_memory(&self.http_client, &self.runtime, &request, capacity)
544 .await
545 },
546 )
547 .await
548 .map_err(DownloadRequestError::into_anyhow)
549 }
550
551 #[cfg_attr(
557 feature = "tracing",
558 tracing::instrument(
559 name = "wa.media.fetch_sticker_pack",
560 level = "debug",
561 skip_all,
562 err(Debug)
563 )
564 )]
565 pub async fn fetch_sticker_pack(
566 &self,
567 pack_id: &str,
568 locale: &str,
569 ) -> Result<wacore::sticker_pack::StickerPack> {
570 let url = wacore::sticker_pack::sticker_pack_data_url(pack_id, locale);
571 let response = self
572 .http_client
573 .execute(crate::http::HttpRequest::get(&url))
574 .await
575 .map_err(|e| anyhow!("sticker pack request failed: {e}"))?;
576 if response.status_code != HTTP_STATUS_OK {
577 let status = response.status_code;
578 return Err(HttpStatusError { status }
579 .into_error(format!("sticker pack endpoint returned status {status}")));
580 }
581 wacore::sticker_pack::parse_sticker_pack_response(&response.body)
582 }
583
584 #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.download_from_params", level = "debug", skip_all, fields(kind = ?params.media_type), err(Debug)))]
586 pub async fn download_from_params(&self, params: &DownloadParams) -> Result<Vec<u8>> {
587 self.download(params).await
588 }
589
590 async fn prepare_requests(
591 &self,
592 downloadable: &dyn Downloadable,
593 force_refresh: bool,
594 ) -> Result<Vec<wacore::download::DownloadRequest>> {
595 let route = if downloadable.static_url().is_some() {
598 MediaRoute::unauthenticated(Vec::new())
599 } else {
600 MediaRoute::from(&self.refresh_media_conn(force_refresh).await?)
601 };
602 DownloadUtils::prepare_download_requests(downloadable, &route)
603 }
604
605 #[cfg_attr(
624 feature = "tracing",
625 tracing::instrument(
626 name = "wa.media.download_to_writer",
627 level = "debug",
628 skip_all,
629 err(Debug)
630 )
631 )]
632 pub async fn download_to_writer<W: DownloadWriter + Send + 'static>(
633 &self,
634 downloadable: &dyn Downloadable,
635 writer: W,
636 ) -> Result<W> {
637 download_to_writer_with_retry(
638 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
639 &self.runtime,
640 writer,
641 |force| self.prepare_requests(downloadable, force),
642 || async { self.invalidate_media_conn().await },
643 |request, writer| async move {
644 streaming_download_and_decrypt(&self.http_client, &self.runtime, &request, writer)
645 .await
646 },
647 )
648 .await
649 .map_err(DownloadRequestError::into_anyhow)
650 }
651
652 #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.download_from_params_to_writer", level = "debug", skip_all, fields(kind = ?params.media_type), err(Debug)))]
655 pub async fn download_from_params_to_writer<W: DownloadWriter + Send + 'static>(
656 &self,
657 params: &DownloadParams,
658 writer: W,
659 ) -> Result<W> {
660 self.download_to_writer(params, writer).await
661 }
662}
663
664fn clear_writer<W: DownloadWriter>(writer: &mut W) -> std::io::Result<()> {
672 writer.truncate(0)?;
673 writer.rewind()?;
674 Ok(())
675}
676
677fn finish_verified_write<W: DownloadWriter>(
684 writer: &mut W,
685) -> std::result::Result<(), DownloadRequestError> {
686 writer.rewind().map_err(DownloadRequestError::other)
687}
688
689async fn discard_failed_write<W: DownloadWriter + Send + 'static>(
700 runtime: &Arc<dyn Runtime>,
701 writer: W,
702) {
703 let mut writer = writer;
704 wacore::runtime::blocking(&**runtime, move || {
705 if let Err(e) = clear_writer(&mut writer) {
706 log::warn!(
707 "Failed to empty the writer after a failed media download: {e}. \
708 It may still hold unverified bytes."
709 );
710 }
711 })
712 .await
713}
714
715async fn streaming_download_and_decrypt<W: DownloadWriter + Send + 'static>(
718 http_client: &Arc<dyn HttpClient>,
719 runtime: &Arc<dyn Runtime>,
720 request: &wacore::download::DownloadRequest,
721 writer: W,
722) -> Result<(W, std::result::Result<(), DownloadRequestError>)> {
723 if !http_client.supports_streaming() {
724 return buffered_download_and_decrypt(http_client, runtime, request, writer).await;
725 }
726
727 let http_client = http_client.clone();
728 let url = request.url.clone();
729 let decryption = request.decryption.clone();
730
731 Ok(wacore::runtime::blocking(&**runtime, move || {
732 let mut writer = writer;
733
734 if let Err(e) = clear_writer(&mut writer) {
735 return (writer, Err(DownloadRequestError::other(e)));
736 }
737
738 let result = (|| -> std::result::Result<(), DownloadRequestError> {
739 let http_request = crate::http::HttpRequest::get(url);
740 let resp = http_client
741 .execute_streaming(http_request)
742 .map_err(DownloadRequestError::other)?;
743
744 validate_download_status(resp.status_code)?;
745
746 match &decryption {
747 MediaDecryption::Encrypted {
748 media_key,
749 media_type,
750 } => {
751 DownloadUtils::decrypt_stream_to_writer(
752 resp.body,
753 media_key,
754 *media_type,
755 &mut writer,
756 )
757 .map_err(DownloadRequestError::other)?;
758 }
759 MediaDecryption::Plaintext { file_sha256 } => {
760 DownloadUtils::copy_and_validate_plaintext_to_writer(
761 resp.body,
762 file_sha256,
763 &mut writer,
764 )
765 .map_err(DownloadRequestError::other)?;
766 }
767 }
768 finish_verified_write(&mut writer)
769 })();
770
771 (writer, result)
772 })
773 .await)
774}
775
776async fn buffered_download_and_decrypt<W: DownloadWriter + Send + 'static>(
778 http_client: &Arc<dyn HttpClient>,
779 runtime: &Arc<dyn Runtime>,
780 request: &wacore::download::DownloadRequest,
781 writer: W,
782) -> Result<(W, std::result::Result<(), DownloadRequestError>)> {
783 let mut body = match buffered_download_body(http_client, request).await {
784 Ok(body) => body,
785 Err(err) => return Ok((writer, Err(err))),
786 };
787 let decryption = request.decryption.clone();
788
789 Ok(wacore::runtime::blocking(&**runtime, move || {
792 let mut writer = writer;
793 let result = (|| {
794 decrypt_or_validate_buffered_body(&mut body, &decryption)?;
795 clear_writer(&mut writer).map_err(DownloadRequestError::other)?;
796 writer
797 .write_all(&body)
798 .map_err(DownloadRequestError::other)?;
799 finish_verified_write(&mut writer)
800 })();
801
802 (writer, result)
803 })
804 .await)
805}
806
807async fn buffered_download_body(
808 http_client: &Arc<dyn HttpClient>,
809 request: &wacore::download::DownloadRequest,
810) -> std::result::Result<Vec<u8>, DownloadRequestError> {
811 let http_request = crate::http::HttpRequest::get(request.url.clone());
812 let response = http_client
813 .execute(http_request)
814 .await
815 .map_err(DownloadRequestError::other)?;
816 validate_download_status(response.status_code)?;
817 Ok(response.body)
818}
819
820async fn buffered_download_to_vec(
824 http_client: &Arc<dyn HttpClient>,
825 runtime: &Arc<dyn Runtime>,
826 request: &wacore::download::DownloadRequest,
827) -> std::result::Result<Vec<u8>, DownloadRequestError> {
828 let mut body = buffered_download_body(http_client, request).await?;
829 let decryption = request.decryption.clone();
830 wacore::runtime::blocking(&**runtime, move || {
831 decrypt_or_validate_buffered_body(&mut body, &decryption)?;
832 Ok(body)
833 })
834 .await
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840 use crate::error::ErrorChainExt;
841 use crate::mediaconn::{MediaConn, MediaConnHost};
842 use async_lock::Mutex;
843 use std::io::{Cursor, Seek, SeekFrom, Write};
844 use std::sync::Arc;
845 use wacore::time::Instant;
846 use waproto::whatsapp as wa;
847
848 struct PlaintextDownloadable {
849 direct_path: String,
850 file_sha256: Vec<u8>,
851 }
852
853 impl Downloadable for PlaintextDownloadable {
854 fn direct_path(&self) -> Option<&str> {
855 Some(&self.direct_path)
856 }
857
858 fn media_key(&self) -> Option<&[u8]> {
859 None
860 }
861
862 fn file_enc_sha256(&self) -> Option<&[u8]> {
863 None
864 }
865
866 fn file_sha256(&self) -> Option<&[u8]> {
867 Some(&self.file_sha256)
868 }
869
870 fn file_length(&self) -> Option<u64> {
871 None
872 }
873
874 fn app_info(&self) -> MediaType {
875 MediaType::Image
876 }
877 }
878
879 fn media_conn(auth: &str, hosts: &[&str]) -> MediaConn {
880 MediaConn {
881 auth: auth.to_string(),
882 ttl: 60,
883 auth_ttl: None,
884 hosts: hosts
885 .iter()
886 .map(|hostname| MediaConnHost::new((*hostname).to_string()))
887 .collect(),
888 fetched_at: Instant::now(),
889 }
890 }
891
892 fn plaintext_sha256(data: &[u8]) -> Vec<u8> {
893 wacore::upload::encrypt_media(data, MediaType::Image)
894 .expect("hash derivation should succeed")
895 .file_sha256
896 .to_vec()
897 }
898
899 #[cfg(feature = "ureq-client")]
902 fn spawn_cdn_server(status: u16, reason: &'static str, body: Vec<u8>) -> String {
903 use std::io::Read;
904 use std::net::TcpListener;
905
906 let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
907 let addr = listener.local_addr().expect("local addr");
908 std::thread::spawn(move || {
909 let Ok((mut stream, _)) = listener.accept() else {
910 return;
911 };
912 let mut buf = Vec::new();
913 let mut tmp = [0u8; 1024];
914 while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
915 match stream.read(&mut tmp) {
916 Ok(0) | Err(_) => return,
917 Ok(n) => buf.extend_from_slice(&tmp[..n]),
918 }
919 }
920 let header = format!(
921 "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
922 body.len()
923 );
924 let _ = stream.write_all(header.as_bytes());
925 let _ = stream.write_all(&body);
926 });
927 format!("http://{addr}")
928 }
929
930 #[cfg(feature = "ureq-client")]
931 fn spawn_cdn_status_server(status: u16, reason: &'static str) -> String {
932 spawn_cdn_server(status, reason, b"denied".to_vec())
933 }
934
935 #[cfg(feature = "ureq-client")]
936 fn plaintext_request(url: String) -> wacore::download::DownloadRequest {
937 wacore::download::DownloadRequest {
938 url,
939 decryption: MediaDecryption::Plaintext {
940 file_sha256: vec![0u8; 32],
941 },
942 }
943 }
944
945 #[cfg(feature = "ureq-client")]
946 async fn ureq_client() -> Arc<Client> {
947 crate::test_utils::create_test_client_with_http(
948 "cdn-status",
949 Arc::new(whatsapp_rust_ureq_http_client::UreqHttpClient::new()),
950 )
951 .await
952 }
953
954 #[cfg(feature = "ureq-client")]
961 #[tokio::test]
962 async fn cdn_auth_status_reaches_the_classifier_on_the_streaming_path() {
963 for status in [401u16, 403] {
964 let url = spawn_cdn_status_server(status, "Forbidden");
965 let client = ureq_client().await;
966 let (_writer, result) = streaming_download_and_decrypt(
967 &client.http_client,
968 &client.runtime,
969 &plaintext_request(url),
970 Cursor::new(Vec::new()),
971 )
972 .await
973 .expect("the request itself completes; the status is the failure");
974 let err = result.expect_err("a non-2xx CDN response must fail the download");
975 assert!(
976 err.is_auth(),
977 "{status} must classify as an auth error so the media conn is refreshed, got {err:?}"
978 );
979 }
980 }
981
982 #[cfg(feature = "ureq-client")]
983 #[tokio::test]
984 async fn cdn_expired_status_reaches_the_classifier_on_the_buffered_path() {
985 for status in [404u16, 410] {
986 let url = spawn_cdn_status_server(status, "Gone");
987 let client = ureq_client().await;
988 let err = buffered_download_body(&client.http_client, &plaintext_request(url))
989 .await
990 .expect_err("a non-2xx CDN response must fail the download");
991 assert!(
992 err.is_not_found(),
993 "{status} must classify as expired so the URL is re-derived, got {err:?}"
994 );
995 }
996 }
997
998 #[cfg(feature = "ureq-client")]
1003 #[tokio::test]
1004 async fn stale_auth_403_invalidates_the_media_conn_and_the_retry_recovers() {
1005 let body = b"download me".to_vec();
1006 let file_sha256 = plaintext_sha256(&body);
1007 let stale_host = spawn_cdn_status_server(403, "Forbidden");
1008 let fresh_host = spawn_cdn_server(200, "OK", body.clone());
1009 let client = ureq_client().await;
1010 let invalidations = Arc::new(Mutex::new(0usize));
1011 let attempts = Arc::new(Mutex::new(Vec::new()));
1012
1013 let downloaded = download_media_with_retry(
1014 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
1015 {
1016 let attempts = Arc::clone(&attempts);
1017 move |force| {
1018 let attempts = Arc::clone(&attempts);
1019 let url = if force {
1020 fresh_host.clone()
1021 } else {
1022 stale_host.clone()
1023 };
1024 let file_sha256 = file_sha256.clone();
1025 async move {
1026 attempts.lock().await.push(force);
1027 Ok(vec![wacore::download::DownloadRequest {
1028 url,
1029 decryption: MediaDecryption::Plaintext { file_sha256 },
1030 }])
1031 }
1032 }
1033 },
1034 {
1035 let invalidations = Arc::clone(&invalidations);
1036 move || {
1037 let invalidations = Arc::clone(&invalidations);
1038 async move {
1039 *invalidations.lock().await += 1;
1040 }
1041 }
1042 },
1043 |request| {
1045 let client = Arc::clone(&client);
1046 async move {
1047 match streaming_download_and_decrypt(
1048 &client.http_client,
1049 &client.runtime,
1050 &request,
1051 Cursor::new(Vec::new()),
1052 )
1053 .await
1054 {
1055 Ok((writer, Ok(()))) => Ok(writer.into_inner()),
1056 Ok((_, Err(e))) => Err(e),
1057 Err(e) => Err(DownloadRequestError::other(e)),
1058 }
1059 }
1060 },
1061 )
1062 .await
1063 .expect("the forced-refresh retry must recover the download");
1064
1065 assert_eq!(downloaded, body);
1066 assert_eq!(
1067 *invalidations.lock().await,
1068 1,
1069 "a 403 must invalidate the cached media conn"
1070 );
1071 assert_eq!(
1072 *attempts.lock().await,
1073 vec![false, true],
1074 "the second attempt must ask for a refreshed media conn"
1075 );
1076 }
1077
1078 #[cfg(feature = "ureq-client")]
1088 #[tokio::test]
1089 async fn the_cdn_status_survives_to_the_public_error() {
1090 for (status, reason) in [
1094 (403u16, "Forbidden"),
1095 (410, "Gone"),
1096 (429, "Too Many Requests"),
1097 ] {
1098 let first = spawn_cdn_status_server(status, reason);
1103 let refreshed = spawn_cdn_status_server(status, reason);
1104 let client = ureq_client().await;
1105
1106 let err = download_media_with_retry(
1109 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
1110 move |force| {
1111 let url = if force {
1112 refreshed.clone()
1113 } else {
1114 first.clone()
1115 };
1116 async move { Ok(vec![plaintext_request(url)]) }
1117 },
1118 || async {},
1119 |request| {
1120 let client = Arc::clone(&client);
1121 async move {
1122 match streaming_download_and_decrypt(
1123 &client.http_client,
1124 &client.runtime,
1125 &request,
1126 Cursor::new(Vec::new()),
1127 )
1128 .await
1129 {
1130 Ok((writer, Ok(()))) => Ok(writer.into_inner()),
1131 Ok((_, Err(e))) => Err(e),
1132 Err(e) => Err(DownloadRequestError::other(e)),
1133 }
1134 }
1135 },
1136 )
1137 .await
1138 .map_err(DownloadRequestError::into_anyhow)
1139 .expect_err("a non-2xx CDN response must fail the download");
1140
1141 let cause: &(dyn std::error::Error + 'static) = err.as_ref();
1142 assert_eq!(
1143 cause.http_status(),
1144 Some(status),
1145 "the consumer must recover {status} by type, got: {err:?}"
1146 );
1147 assert!(
1150 format!("{err}").contains(&status.to_string()),
1151 "the message should still name the status, got: {err}"
1152 );
1153 }
1154 }
1155
1156 #[cfg(feature = "ureq-client")]
1160 #[tokio::test]
1161 async fn a_failure_with_no_exchange_reports_no_status() {
1162 let client = ureq_client().await;
1163 let request = plaintext_request("http://127.0.0.1:1".to_string());
1165
1166 let err = download_media_with_retry(
1167 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
1168 move |_force| {
1169 let request = request.clone();
1170 async move { Ok(vec![request]) }
1171 },
1172 || async {},
1173 |request| {
1174 let client = Arc::clone(&client);
1175 async move {
1176 match streaming_download_and_decrypt(
1177 &client.http_client,
1178 &client.runtime,
1179 &request,
1180 Cursor::new(Vec::new()),
1181 )
1182 .await
1183 {
1184 Ok((writer, Ok(()))) => Ok(writer.into_inner()),
1185 Ok((_, Err(e))) => Err(e),
1186 Err(e) => Err(DownloadRequestError::other(e)),
1187 }
1188 }
1189 },
1190 )
1191 .await
1192 .map_err(DownloadRequestError::into_anyhow)
1193 .expect_err("a refused connection must fail the download");
1194
1195 let cause: &(dyn std::error::Error + 'static) = err.as_ref();
1196 assert_eq!(
1197 cause.http_status(),
1198 None,
1199 "a transport failure must not be laundered into an upstream status, got: {err:?}"
1200 );
1201 }
1202
1203 #[test]
1204 fn download_statuses_have_one_shared_classification() {
1205 use crate::http::{HTTP_STATUS_FORBIDDEN, HTTP_STATUS_UNAUTHORIZED};
1206
1207 assert!(validate_download_status(HTTP_STATUS_OK).is_ok());
1208 assert!(matches!(
1209 validate_download_status(HTTP_STATUS_UNAUTHORIZED),
1210 Err(DownloadRequestError::Auth(_))
1211 ));
1212 assert!(matches!(
1213 validate_download_status(HTTP_STATUS_FORBIDDEN),
1214 Err(DownloadRequestError::Auth(_))
1215 ));
1216 assert!(matches!(
1217 validate_download_status(HTTP_STATUS_NOT_FOUND),
1218 Err(DownloadRequestError::NotFound(_))
1219 ));
1220 assert!(matches!(
1221 validate_download_status(HTTP_STATUS_GONE),
1222 Err(DownloadRequestError::NotFound(_))
1223 ));
1224 assert!(matches!(
1225 validate_download_status(HTTP_STATUS_REDIRECTION_START),
1226 Err(DownloadRequestError::Other(_))
1227 ));
1228 }
1229
1230 #[test]
1231 fn process_downloaded_media_ok() {
1232 let data = b"Hello media test";
1233 let enc = wacore::upload::encrypt_media(data, MediaType::Image)
1234 .expect("encryption should succeed");
1235 let mut cursor = Cursor::new(Vec::<u8>::new());
1236 let plaintext = DownloadUtils::verify_and_decrypt(
1237 &enc.data_to_upload,
1238 &enc.media_key,
1239 MediaType::Image,
1240 )
1241 .expect("decryption should succeed");
1242 cursor.write_all(&plaintext).expect("write should succeed");
1243 assert_eq!(cursor.into_inner(), data);
1244 }
1245
1246 #[test]
1247 fn process_downloaded_media_bad_mac() {
1248 let data = b"Tamper";
1249 let mut enc = wacore::upload::encrypt_media(data, MediaType::Image)
1250 .expect("encryption should succeed");
1251 let last = enc.data_to_upload.len() - 1;
1252 enc.data_to_upload[last] ^= 0x01;
1253
1254 let err = DownloadUtils::verify_and_decrypt(
1255 &enc.data_to_upload,
1256 &enc.media_key,
1257 MediaType::Image,
1258 )
1259 .unwrap_err();
1260
1261 assert!(
1262 matches!(&err, MediaDecryptionError::InvalidMac),
1263 "Expected InvalidMac, got: {}",
1264 err
1265 );
1266 }
1267
1268 #[tokio::test]
1271 async fn download_retries_with_forced_media_conn_refresh_after_auth_error() {
1272 let body = b"download me".to_vec();
1273 let downloadable = PlaintextDownloadable {
1274 direct_path: "/v/t62.7118-24/123".to_string(),
1275 file_sha256: plaintext_sha256(&body),
1276 };
1277 let first_conn = media_conn("stale-auth", &["cdn1.example.com"]);
1278 let refreshed_conn = media_conn("fresh-auth", &["cdn2.example.com"]);
1279 let refresh_calls = Arc::new(Mutex::new(Vec::new()));
1280 let invalidations = Arc::new(Mutex::new(0usize));
1281 let seen_urls = Arc::new(Mutex::new(Vec::new()));
1282
1283 let downloaded = download_media_with_retry(
1284 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
1285 {
1286 let refresh_calls = Arc::clone(&refresh_calls);
1287 let downloadable = &downloadable;
1288 move |force| {
1289 let refresh_calls = Arc::clone(&refresh_calls);
1290 let first_conn = first_conn.clone();
1291 let refreshed_conn = refreshed_conn.clone();
1292 async move {
1293 refresh_calls.lock().await.push(force);
1294 let media_conn = if force { refreshed_conn } else { first_conn };
1295 DownloadUtils::prepare_download_requests(
1296 downloadable,
1297 &MediaRoute::from(&media_conn),
1298 )
1299 }
1300 }
1301 },
1302 {
1303 let invalidations = Arc::clone(&invalidations);
1304 move || {
1305 let invalidations = Arc::clone(&invalidations);
1306 async move {
1307 *invalidations.lock().await += 1;
1308 }
1309 }
1310 },
1311 {
1312 let seen_urls = Arc::clone(&seen_urls);
1313 let body = body.clone();
1314 move |request| {
1315 let seen_urls = Arc::clone(&seen_urls);
1316 let body = body.clone();
1317 let url = request.url.clone();
1318 async move {
1319 seen_urls.lock().await.push(url.clone());
1320 if url.contains("stale-auth") {
1321 Err(DownloadRequestError::auth(401))
1322 } else {
1323 Ok(body)
1324 }
1325 }
1326 }
1327 },
1328 )
1329 .await
1330 .expect("download should succeed after refreshing media auth");
1331
1332 assert_eq!(downloaded, body);
1333 assert_eq!(*refresh_calls.lock().await, vec![false, true]);
1334 assert_eq!(*invalidations.lock().await, 1);
1335
1336 let seen_urls = seen_urls.lock().await.clone();
1337 assert_eq!(seen_urls.len(), 2);
1338 assert!(seen_urls[0].contains("auth=stale-auth"));
1339 assert!(seen_urls[1].contains("auth=fresh-auth"));
1340 }
1341
1342 #[tokio::test]
1345 async fn download_fails_over_to_next_host_without_refresh() {
1346 let body = b"failover me".to_vec();
1347 let downloadable = PlaintextDownloadable {
1348 direct_path: "/v/t62.7118-24/failover".to_string(),
1349 file_sha256: plaintext_sha256(&body),
1350 };
1351 let conn = media_conn(
1352 "auth-tok",
1353 &["bad-host.example.com", "good-host.example.com"],
1354 );
1355 let refresh_calls = Arc::new(Mutex::new(Vec::new()));
1356 let invalidations = Arc::new(Mutex::new(0usize));
1357 let seen_urls = Arc::new(Mutex::new(Vec::new()));
1358
1359 let downloaded = download_media_with_retry(
1360 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
1361 {
1362 let refresh_calls = Arc::clone(&refresh_calls);
1363 let downloadable = &downloadable;
1364 let conn = conn.clone();
1365 move |force| {
1366 let refresh_calls = Arc::clone(&refresh_calls);
1367 let conn = conn.clone();
1368 async move {
1369 refresh_calls.lock().await.push(force);
1370 DownloadUtils::prepare_download_requests(
1371 downloadable,
1372 &MediaRoute::from(&conn),
1373 )
1374 }
1375 }
1376 },
1377 {
1378 let invalidations = Arc::clone(&invalidations);
1379 move || {
1380 let invalidations = Arc::clone(&invalidations);
1381 async move {
1382 *invalidations.lock().await += 1;
1383 }
1384 }
1385 },
1386 {
1387 let seen_urls = Arc::clone(&seen_urls);
1388 let body = body.clone();
1389 move |request| {
1390 let seen_urls = Arc::clone(&seen_urls);
1391 let body = body.clone();
1392 let url = request.url.clone();
1393 async move {
1394 seen_urls.lock().await.push(url.clone());
1395 if url.contains("bad-host") {
1396 Err(DownloadRequestError::other(anyhow!("connection reset")))
1397 } else {
1398 Ok(body)
1399 }
1400 }
1401 }
1402 },
1403 )
1404 .await
1405 .expect("download should fail over to the healthy host");
1406
1407 assert_eq!(downloaded, body);
1408 assert_eq!(*refresh_calls.lock().await, vec![false]);
1410 assert_eq!(*invalidations.lock().await, 0);
1411 let seen_urls = seen_urls.lock().await.clone();
1412 assert_eq!(seen_urls.len(), 2);
1413 assert!(seen_urls[0].contains("bad-host"));
1414 assert!(seen_urls[1].contains("good-host"));
1415 }
1416
1417 #[tokio::test]
1420 async fn download_propagates_last_error_when_all_hosts_fail() {
1421 let body = b"never arrives".to_vec();
1422 let downloadable = PlaintextDownloadable {
1423 direct_path: "/v/t62.7118-24/allfail".to_string(),
1424 file_sha256: plaintext_sha256(&body),
1425 };
1426 let conn = media_conn("auth-tok", &["host-a.example.com", "host-b.example.com"]);
1427 let invalidations = Arc::new(Mutex::new(0usize));
1428 let seen_urls = Arc::new(Mutex::new(Vec::new()));
1429
1430 let err = download_media_with_retry(
1431 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
1432 {
1433 let downloadable = &downloadable;
1434 let conn = conn.clone();
1435 move |_force| {
1436 let conn = conn.clone();
1437 async move {
1438 DownloadUtils::prepare_download_requests(
1439 downloadable,
1440 &MediaRoute::from(&conn),
1441 )
1442 }
1443 }
1444 },
1445 {
1446 let invalidations = Arc::clone(&invalidations);
1447 move || {
1448 let invalidations = Arc::clone(&invalidations);
1449 async move {
1450 *invalidations.lock().await += 1;
1451 }
1452 }
1453 },
1454 {
1455 let seen_urls = Arc::clone(&seen_urls);
1456 move |request| {
1457 let seen_urls = Arc::clone(&seen_urls);
1458 let url = request.url.clone();
1459 async move {
1460 seen_urls.lock().await.push(url.clone());
1461 Err::<Vec<u8>, _>(DownloadRequestError::other(anyhow!("host {url} down")))
1462 }
1463 }
1464 },
1465 )
1466 .await
1467 .expect_err("all hosts failing must surface an error")
1468 .into_anyhow();
1469
1470 assert!(
1471 err.to_string().contains("down"),
1472 "expected the propagated last_err, got: {err}"
1473 );
1474 assert_eq!(*invalidations.lock().await, 0);
1475 assert_eq!(seen_urls.lock().await.len(), 2);
1476 }
1477
1478 #[tokio::test]
1479 async fn download_to_writer_retries_with_forced_media_conn_refresh_after_auth_error() {
1480 let body = b"stream me".to_vec();
1481 let downloadable = PlaintextDownloadable {
1482 direct_path: "/v/t62.7118-24/stream".to_string(),
1483 file_sha256: plaintext_sha256(&body),
1484 };
1485 let first_conn = media_conn("stale-auth", &["cdn1.example.com"]);
1486 let refreshed_conn = media_conn("fresh-auth", &["cdn2.example.com"]);
1487 let refresh_calls = Arc::new(Mutex::new(Vec::new()));
1488 let invalidations = Arc::new(Mutex::new(0usize));
1489 let seen_urls = Arc::new(Mutex::new(Vec::new()));
1490
1491 let runtime: Arc<dyn Runtime> = Arc::new(crate::TokioRuntime);
1492 let writer = download_to_writer_with_retry(
1493 MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS,
1494 &runtime,
1495 Cursor::new(Vec::<u8>::new()),
1496 {
1497 let refresh_calls = Arc::clone(&refresh_calls);
1498 let downloadable = &downloadable;
1499 move |force| {
1500 let refresh_calls = Arc::clone(&refresh_calls);
1501 let first_conn = first_conn.clone();
1502 let refreshed_conn = refreshed_conn.clone();
1503 async move {
1504 refresh_calls.lock().await.push(force);
1505 let media_conn = if force { refreshed_conn } else { first_conn };
1506 DownloadUtils::prepare_download_requests(
1507 downloadable,
1508 &MediaRoute::from(&media_conn),
1509 )
1510 }
1511 }
1512 },
1513 {
1514 let invalidations = Arc::clone(&invalidations);
1515 move || {
1516 let invalidations = Arc::clone(&invalidations);
1517 async move {
1518 *invalidations.lock().await += 1;
1519 }
1520 }
1521 },
1522 {
1523 let seen_urls = Arc::clone(&seen_urls);
1524 let body = body.clone();
1525 move |request, mut writer| {
1526 let seen_urls = Arc::clone(&seen_urls);
1527 let body = body.clone();
1528 let url = request.url.clone();
1529 async move {
1530 seen_urls.lock().await.push(url.clone());
1531 writer.seek(SeekFrom::Start(0))?;
1532 if url.contains("stale-auth") {
1533 Ok((writer, Err(DownloadRequestError::auth(403))))
1534 } else {
1535 writer.write_all(&body)?;
1536 writer.seek(SeekFrom::Start(0))?;
1537 Ok((writer, Ok(())))
1538 }
1539 }
1540 }
1541 },
1542 )
1543 .await
1544 .expect("streaming download should succeed after refreshing media auth");
1545
1546 assert_eq!(writer.into_inner(), body);
1547 assert_eq!(*refresh_calls.lock().await, vec![false, true]);
1548 assert_eq!(*invalidations.lock().await, 1);
1549
1550 let seen_urls = seen_urls.lock().await.clone();
1551 assert_eq!(seen_urls.len(), 2);
1552 assert!(seen_urls[0].contains("auth=stale-auth"));
1553 assert!(seen_urls[1].contains("auth=fresh-auth"));
1554 }
1555
1556 struct RoutedHttpClient {
1561 routes: Vec<(&'static str, u16, Vec<u8>)>,
1562 fallback: (u16, Vec<u8>),
1563 streaming: bool,
1564 seen_urls: std::sync::Mutex<Vec<String>>,
1566 }
1567
1568 impl RoutedHttpClient {
1569 fn new(routes: Vec<(&'static str, u16, Vec<u8>)>, fallback: (u16, Vec<u8>)) -> Arc<Self> {
1570 Arc::new(Self {
1571 routes,
1572 fallback,
1573 streaming: false,
1574 seen_urls: std::sync::Mutex::new(Vec::new()),
1575 })
1576 }
1577
1578 fn streaming(
1579 routes: Vec<(&'static str, u16, Vec<u8>)>,
1580 fallback: (u16, Vec<u8>),
1581 ) -> Arc<Self> {
1582 Arc::new(Self {
1583 routes,
1584 fallback,
1585 streaming: true,
1586 seen_urls: std::sync::Mutex::new(Vec::new()),
1587 })
1588 }
1589 }
1590
1591 impl RoutedHttpClient {
1592 fn record(&self, url: &str) {
1593 self.seen_urls
1594 .lock()
1595 .expect("test mutex is never poisoned")
1596 .push(url.to_string());
1597 }
1598
1599 fn urls(&self) -> Vec<String> {
1600 self.seen_urls
1601 .lock()
1602 .expect("test mutex is never poisoned")
1603 .clone()
1604 }
1605
1606 fn respond(&self, url: &str) -> (u16, Vec<u8>) {
1607 self.routes
1608 .iter()
1609 .find(|(needle, _, _)| url.contains(needle))
1610 .map(|(_, status, body)| (*status, body.clone()))
1611 .unwrap_or_else(|| self.fallback.clone())
1612 }
1613 }
1614
1615 #[async_trait::async_trait]
1616 impl HttpClient for RoutedHttpClient {
1617 async fn execute(
1618 &self,
1619 request: crate::http::HttpRequest,
1620 ) -> Result<crate::http::HttpResponse> {
1621 self.record(&request.url);
1622 let (status_code, body) = self.respond(&request.url);
1623 Ok(crate::http::HttpResponse { status_code, body })
1624 }
1625
1626 fn supports_streaming(&self) -> bool {
1629 self.streaming
1630 }
1631
1632 fn execute_streaming(
1633 &self,
1634 request: crate::http::HttpRequest,
1635 ) -> Result<wacore::net::StreamingHttpResponse> {
1636 self.record(&request.url);
1637 let (status_code, body) = self.respond(&request.url);
1638 Ok(wacore::net::StreamingHttpResponse {
1639 status_code,
1640 body: Box::new(Cursor::new(body)),
1641 })
1642 }
1643 }
1644
1645 fn downloader(http: Arc<RoutedHttpClient>, hosts: &[&str]) -> MediaDownloader {
1646 MediaDownloader::new(
1647 http,
1648 Arc::new(crate::TokioRuntime),
1649 MediaRoute::unauthenticated(hosts.iter().copied().map(MediaHost::new).collect()),
1650 )
1651 }
1652
1653 fn encrypted_params(data: &[u8]) -> (DownloadParams, Vec<u8>) {
1654 let enc = wacore::upload::encrypt_media(data, MediaType::Image)
1655 .expect("encryption should succeed");
1656 let params = DownloadParams::encrypted(
1657 "/v/t62.7118-24/no-session",
1658 &enc.media_key,
1659 &enc.file_sha256,
1660 &enc.file_enc_sha256,
1661 data.len() as u64,
1662 MediaType::Image,
1663 );
1664 (params, enc.data_to_upload)
1665 }
1666
1667 fn forged_body(plaintext: &[u8], media_key: &[u8; 32]) -> Vec<u8> {
1671 let mut body =
1672 wacore::upload::encrypt_media_with_key(plaintext, MediaType::Image, Some(media_key))
1673 .expect("encryption should succeed")
1674 .data_to_upload;
1675 let last = body.len() - 1;
1676 body[last] ^= 1;
1677 body
1678 }
1679
1680 #[tokio::test]
1686 async fn a_failed_host_leaves_no_tail_behind_a_shorter_successful_retry() {
1687 let media_key = [0x5b; 32];
1688 let original = b"short but verified".to_vec();
1689 let good =
1690 wacore::upload::encrypt_media_with_key(&original, MediaType::Image, Some(&media_key))
1691 .expect("encryption should succeed");
1692 let params = DownloadParams::encrypted(
1693 "/v/t62.7118-24/tail",
1694 &good.media_key,
1695 &good.file_sha256,
1696 &good.file_enc_sha256,
1697 original.len() as u64,
1698 MediaType::Image,
1699 );
1700
1701 let forged = forged_body(&vec![0xAA; 64 * 1024], &media_key);
1704 assert!(forged.len() > good.data_to_upload.len() * 10);
1705
1706 for streaming in [true, false] {
1707 let routes = vec![
1708 ("forging-host", 200, forged.clone()),
1709 ("honest-host", 200, good.data_to_upload.clone()),
1710 ];
1711 let http = if streaming {
1712 RoutedHttpClient::streaming(routes, (500, Vec::new()))
1713 } else {
1714 RoutedHttpClient::new(routes, (500, Vec::new()))
1715 };
1716
1717 let writer = downloader(
1718 http.clone(),
1719 &["forging-host.example.com", "honest-host.example.com"],
1720 )
1721 .download_to_writer(¶ms, Cursor::new(Vec::new()))
1722 .await
1723 .expect("the honest host must still satisfy the download");
1724
1725 assert_eq!(
1726 http.urls().len(),
1727 2,
1728 "the forged host must have been tried first (streaming={streaming})"
1729 );
1730 assert_eq!(
1731 writer.into_inner(),
1732 original,
1733 "the forged host's plaintext must not survive past the media (streaming={streaming})"
1734 );
1735 }
1736 }
1737
1738 #[tokio::test]
1741 async fn a_writer_that_arrives_with_content_still_ends_up_holding_only_the_media() {
1742 let original = b"exactly this".to_vec();
1743 let (params, encrypted) = encrypted_params(&original);
1744 let http = RoutedHttpClient::streaming(Vec::new(), (200, encrypted));
1745
1746 let writer = downloader(http, &["cdn.example.com"])
1747 .download_to_writer(¶ms, Cursor::new(vec![0xFF; 4096]))
1748 .await
1749 .expect("download should succeed");
1750
1751 assert_eq!(writer.into_inner(), original);
1752 }
1753
1754 #[tokio::test]
1760 async fn an_append_mode_file_still_ends_up_holding_only_the_media() {
1761 let original = b"appended, yet exact".to_vec();
1762 let (params, encrypted) = encrypted_params(&original);
1763
1764 let path = std::env::temp_dir().join(format!(
1765 "wa-rust-append-{}-{:?}.bin",
1766 std::process::id(),
1767 std::thread::current().id()
1768 ));
1769 std::fs::write(&path, b"stale bytes the caller left behind")
1770 .expect("fixture write should succeed");
1771 let file = std::fs::OpenOptions::new()
1772 .append(true)
1773 .open(&path)
1774 .expect("append open should succeed");
1775
1776 let http = RoutedHttpClient::streaming(Vec::new(), (200, encrypted));
1777 downloader(http, &["cdn.example.com"])
1778 .download_to_writer(¶ms, file)
1779 .await
1780 .expect("download should succeed");
1781
1782 let written = std::fs::read(&path).expect("read back should succeed");
1783 let _ = std::fs::remove_file(&path);
1784 assert_eq!(written, original);
1785 }
1786
1787 #[derive(Clone, Debug)]
1794 struct SharedWriter(Arc<std::sync::Mutex<Cursor<Vec<u8>>>>);
1795
1796 impl SharedWriter {
1797 fn new() -> Self {
1798 Self(Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new()))))
1799 }
1800
1801 fn contents(&self) -> Vec<u8> {
1802 self.with(|inner| inner.get_ref().clone())
1803 }
1804
1805 fn with<T>(&self, f: impl FnOnce(&mut Cursor<Vec<u8>>) -> T) -> T {
1806 f(&mut self.0.lock().expect("test mutex is never poisoned"))
1807 }
1808 }
1809
1810 impl Write for SharedWriter {
1811 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1812 self.with(|inner| inner.write(buf))
1813 }
1814
1815 fn flush(&mut self) -> std::io::Result<()> {
1816 self.with(|inner| inner.flush())
1817 }
1818 }
1819
1820 impl Seek for SharedWriter {
1821 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
1822 self.with(|inner| inner.seek(pos))
1823 }
1824 }
1825
1826 impl DownloadWriter for SharedWriter {
1827 fn truncate(&mut self, len: u64) -> std::io::Result<()> {
1828 self.with(|inner| inner.truncate(len))
1829 }
1830 }
1831
1832 #[tokio::test]
1835 async fn a_download_that_fails_everywhere_empties_the_writer() {
1836 let media_key = [0x77; 32];
1837 let reference = encrypted_params(b"unused").0;
1838 let params = DownloadParams::encrypted(
1839 "/v/t62.7118-24/doomed",
1840 &media_key,
1841 &reference.file_sha256,
1842 reference.file_enc_sha256.as_deref().unwrap_or_default(),
1843 12,
1844 MediaType::Image,
1845 );
1846 let forged = forged_body(&vec![0x11; 32 * 1024], &media_key);
1847 let http = RoutedHttpClient::streaming(vec![("only-host", 200, forged)], (500, Vec::new()));
1848
1849 let sink = SharedWriter::new();
1850 let err = downloader(http, &["only-host.example.com"])
1851 .download_to_writer(¶ms, sink.clone())
1852 .await
1853 .expect_err("a forged body must not be reported as a download");
1854
1855 assert!(matches!(err, MediaDownloadError::HostsUnreachable(_)));
1856 assert!(
1857 sink.contents().is_empty(),
1858 "a failed download must not leave plaintext behind"
1859 );
1860 }
1861
1862 #[tokio::test]
1863 async fn media_downloader_fetches_without_a_session_or_auth() {
1864 let original = b"media that outlived its session".to_vec();
1865 let (params, encrypted) = encrypted_params(&original);
1866 let http = RoutedHttpClient::new(
1867 vec![("good-host", 200, encrypted)],
1868 (500, b"server error".to_vec()),
1869 );
1870
1871 let downloaded = downloader(
1872 http.clone(),
1873 &["bad-host.example.com", "good-host.example.com"],
1874 )
1875 .download(¶ms)
1876 .await
1877 .expect("an injected host list is all a download needs");
1878
1879 assert_eq!(downloaded, original);
1880 let seen = http.urls();
1881 assert_eq!(seen.len(), 2, "the failing host must fail over to the next");
1882 assert!(
1883 seen[0].starts_with("https://bad-host.example.com/v/t62.7118-24/no-session?token=")
1884 );
1885 assert!(
1886 seen[1].starts_with("https://good-host.example.com/v/t62.7118-24/no-session?token=")
1887 );
1888 assert!(seen.iter().all(|url| !url.contains("auth=")));
1889 }
1890
1891 #[tokio::test]
1892 async fn media_downloader_streams_to_a_writer_without_a_session() {
1893 let original = b"streamed without a session".to_vec();
1894 let (params, encrypted) = encrypted_params(&original);
1895 let http = RoutedHttpClient::streaming(Vec::new(), (200, encrypted.clone()));
1896 assert!(
1897 http.supports_streaming(),
1898 "this must not fall back to buffered"
1899 );
1900
1901 let writer = downloader(http, &["cdn.example.com"])
1902 .download_to_writer(¶ms, Cursor::new(Vec::new()))
1903 .await
1904 .expect("the streaming path must work without a session too");
1905 assert_eq!(writer.into_inner(), original);
1906
1907 let buffered = RoutedHttpClient::new(Vec::new(), (200, encrypted));
1910 let writer = downloader(buffered, &["cdn.example.com"])
1911 .download_to_writer(¶ms, Cursor::new(Vec::new()))
1912 .await
1913 .expect("the buffered fallback must work too");
1914 assert_eq!(writer.into_inner(), original);
1915 }
1916
1917 #[tokio::test]
1920 async fn media_downloader_separates_an_unbuildable_reference_from_a_dead_host() {
1921 let params = DownloadParams {
1922 direct_path: "/v/t62.7118-24/incomplete".to_string(),
1923 media_key: Some(vec![1u8; 32]),
1924 file_sha256: vec![2u8; 32],
1925 file_enc_sha256: None,
1926 file_length: 16,
1927 media_type: MediaType::Image,
1928 };
1929 let http = RoutedHttpClient::new(Vec::new(), (200, Vec::new()));
1930
1931 let err = downloader(http.clone(), &["cdn1.example.com", "cdn2.example.com"])
1932 .download(¶ms)
1933 .await
1934 .expect_err("an incomplete reference cannot succeed");
1935
1936 assert!(
1937 matches!(err, MediaDownloadError::Other(_)),
1938 "a reference that cannot build a URL is not a host failure, got {err:?}"
1939 );
1940 assert!(
1941 err.to_string().contains("Missing file_enc_sha256"),
1942 "the cause must survive the classification, got: {err}"
1943 );
1944 assert!(http.urls().is_empty());
1945 }
1946
1947 #[tokio::test]
1948 async fn media_downloader_separates_an_expired_reference_from_a_dead_host() {
1949 let (params, _) = encrypted_params(b"gone");
1950
1951 let expired = RoutedHttpClient::new(Vec::new(), (410, b"gone".to_vec()));
1952 let err = downloader(expired.clone(), &["cdn1.example.com", "cdn2.example.com"])
1953 .download(¶ms)
1954 .await
1955 .expect_err("an expired reference must not read as success");
1956 assert!(
1957 matches!(err, MediaDownloadError::ReferenceRejected(_)),
1958 "expected a rejected reference, got {err:?}"
1959 );
1960 assert_eq!(
1961 expired.urls().len(),
1962 1,
1963 "no host rotation and no refresh: nothing here can re-sign the reference"
1964 );
1965
1966 let dead = RoutedHttpClient::new(Vec::new(), (500, b"boom".to_vec()));
1967 let err = downloader(dead.clone(), &["cdn1.example.com", "cdn2.example.com"])
1968 .download(¶ms)
1969 .await
1970 .expect_err("every host failing must not read as success");
1971 assert!(
1972 matches!(err, MediaDownloadError::HostsUnreachable(_)),
1973 "expected unreachable hosts, got {err:?}"
1974 );
1975 assert_eq!(dead.urls().len(), 2, "every host is tried");
1976 }
1977
1978 #[tokio::test]
1979 async fn media_downloader_defaults_to_the_known_cdn_hosts() {
1980 let downloader = MediaDownloader::with_default_hosts(
1981 RoutedHttpClient::new(Vec::new(), (200, Vec::new())),
1982 Arc::new(crate::TokioRuntime),
1983 );
1984 assert!(downloader.route().auth.is_none());
1985 assert_eq!(
1986 downloader
1987 .route()
1988 .hosts
1989 .iter()
1990 .map(|h| h.hostname.as_str())
1991 .collect::<Vec<_>>(),
1992 DEFAULT_MEDIA_HOSTS.to_vec(),
1993 );
1994 }
1995
1996 #[tokio::test]
1997 async fn media_downloader_without_hosts_reports_it() {
1998 let (params, _) = encrypted_params(b"nowhere to go");
1999 let http = RoutedHttpClient::new(Vec::new(), (200, Vec::new()));
2000
2001 let err = downloader(http.clone(), &[])
2002 .download(¶ms)
2003 .await
2004 .expect_err("an empty route cannot succeed");
2005
2006 assert!(
2007 matches!(err, MediaDownloadError::NoHosts),
2008 "expected NoHosts, got {err:?}"
2009 );
2010 assert!(http.urls().is_empty());
2011 }
2012
2013 #[tokio::test]
2017 async fn static_url_download_asks_for_no_media_conn() {
2018 let client = crate::test_utils::create_test_client_with_name("static_url_no_iq").await;
2019
2020 let with_static_url = wa::message::ImageMessage {
2021 static_url: Some("https://static.cdn.example.com/media/abc123".to_string()),
2022 direct_path: Some("/v/t62.7118-24/unused".to_string()),
2023 file_sha256: Some(vec![7u8; 32]),
2024 ..Default::default()
2025 };
2026 let requests = client
2027 .prepare_requests(&with_static_url, false)
2028 .await
2029 .expect("a static URL needs no hosts, so it must not need a session");
2030 assert_eq!(requests.len(), 1);
2031 assert_eq!(
2032 requests[0].url,
2033 "https://static.cdn.example.com/media/abc123"
2034 );
2035
2036 let without_static_url = wa::message::ImageMessage {
2039 direct_path: Some("/v/t62.7118-24/needs-hosts".to_string()),
2040 file_sha256: Some(vec![7u8; 32]),
2041 ..Default::default()
2042 };
2043 let err = client
2044 .prepare_requests(&without_static_url, false)
2045 .await
2046 .expect_err("host construction still needs a media conn");
2047 assert!(
2048 err.to_string().contains("not connected"),
2049 "expected the media-conn IQ to be attempted, got: {err}"
2050 );
2051 }
2052
2053 struct CannedHttpClient {
2055 status: u16,
2056 body: Vec<u8>,
2057 seen_url: Mutex<Option<String>>,
2058 }
2059
2060 #[async_trait::async_trait]
2061 impl HttpClient for CannedHttpClient {
2062 async fn execute(
2063 &self,
2064 request: crate::http::HttpRequest,
2065 ) -> Result<crate::http::HttpResponse> {
2066 *self.seen_url.lock().await = Some(request.url);
2067 Ok(crate::http::HttpResponse {
2068 status_code: self.status,
2069 body: self.body.clone(),
2070 })
2071 }
2072 }
2073
2074 #[tokio::test]
2075 async fn fetch_sticker_pack_hits_cdn_and_parses() {
2076 use base64::engine::{Engine, general_purpose::STANDARD};
2077 let body = format!(
2078 r#"[{{"sticker-pack-id":"P1","name":"Cats","stickers":[
2079 {{"media-key":"{}","file-hash":"{}","enc-file-hash":"{}","direct-path":"/d","file-size":9}}
2080 ]}}]"#,
2081 STANDARD.encode([1u8; 32]),
2082 STANDARD.encode([2u8; 32]),
2083 STANDARD.encode([3u8; 32]),
2084 );
2085 let http = Arc::new(CannedHttpClient {
2086 status: 200,
2087 body: body.into_bytes(),
2088 seen_url: Mutex::new(None),
2089 });
2090 let client =
2091 crate::test_utils::create_test_client_with_http("sticker_fetch", http.clone()).await;
2092
2093 let pack = client.fetch_sticker_pack("P1", "en").await.unwrap();
2094 assert_eq!(pack.sticker_pack_id.as_deref(), Some("P1"));
2095 assert_eq!(pack.stickers.len(), 1);
2096 assert_eq!(pack.stickers[0].direct_path(), Some("/d"));
2097
2098 let url = http.seen_url.lock().await.clone().unwrap();
2099 assert_eq!(
2100 url,
2101 "https://static.whatsapp.net/sticker?lottie=1&cat=sticker_pack_data&id=P1&lg=en"
2102 );
2103 }
2104
2105 #[tokio::test]
2106 async fn fetch_sticker_pack_errors_on_non_200() {
2107 let http = Arc::new(CannedHttpClient {
2108 status: 404,
2109 body: Vec::new(),
2110 seen_url: Mutex::new(None),
2111 });
2112 let client = crate::test_utils::create_test_client_with_http("sticker_404", http).await;
2113 let err = client
2114 .fetch_sticker_pack("P1", "en")
2115 .await
2116 .expect_err("a non-200 sticker pack response must fail");
2117 let cause: &(dyn std::error::Error + 'static) = err.as_ref();
2120 assert_eq!(cause.http_status(), Some(404), "got: {err:?}");
2121 }
2122}