Skip to main content

whatsapp_rust/
download.rs

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
16/// Cap on the speculative capacity pre-allocated for the in-memory download
17/// buffer. Sized to the plaintext length the message declares, but a bogus
18/// length must not drive a multi-GB allocation before a single byte arrives;
19/// beyond this the buffer grows on demand. Comfortably above typical
20/// image/video/audio media so the common case is a single allocation.
21const 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
35/// `Downloadable` built from raw CDN fields, for re-downloading media without
36/// the original message in hand.
37pub 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    /// Params for encrypted media. Slices are copied into the owned struct.
48    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/// Why a media download failed, for callers that have no session to refresh.
89///
90/// [`Client`] downloads keep returning [`anyhow::Error`]: a refresh is tried
91/// before the error escapes, so the distinction has already been acted on.
92#[derive(Debug, thiserror::Error)]
93#[non_exhaustive]
94pub enum MediaDownloadError {
95    /// The CDN rejected the reference itself (401/403/404/410). The direct path
96    /// or its token is expired or revoked; another host cannot serve it either.
97    #[error("the CDN rejected the media reference: {0}")]
98    ReferenceRejected(#[source] anyhow::Error),
99    /// Every host in the route failed for a reason other than the reference:
100    /// transport failure, unexpected status, or a body that failed to verify.
101    #[error("every media host failed: {0}")]
102    HostsUnreachable(#[source] anyhow::Error),
103    /// The route named no hosts, so nothing was ever contacted.
104    #[error("the media route names no hosts")]
105    NoHosts,
106    /// No host was contacted and none could be: the reference is too incomplete
107    /// to build a URL from, so the fix is the metadata, not the network.
108    #[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    /// 404/410 — media URL expired or not found. Needs fresh auth + URL re-derivation.
129    /// Matches WA Web's `MediaNotFoundError` handling.
130    NotFound(anyhow::Error),
131    Other(anyhow::Error),
132    /// The request list could not be built, so no host was ever contacted and
133    /// no host ever could be: the reference itself is incomplete.
134    Prepare(anyhow::Error),
135    /// No request was ever executed: the route carried no hosts.
136    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    /// A status this path does not act on itself (429, 5xx, …). Still carries
155    /// the status: the retry loop ignoring it does not mean the caller will,
156    /// and "back off" and "upstream is broken" are its calls to make.
157    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    /// The message stays what it was; the status also becomes a typed node in
165    /// the chain so [`ErrorChainExt::http_status`] can recover it instead of a
166    /// consumer parsing this text.
167    ///
168    /// [`ErrorChainExt::http_status`]: crate::error::ErrorChainExt::http_status
169    fn refused(status_code: u16, context: String) -> anyhow::Error {
170        HttpStatusError {
171            status: status_code,
172        }
173        .into_error(context)
174    }
175
176    /// For failures with no HTTP status at all — a socket that never connected,
177    /// a body that would not decrypt. `http_status()` reports `None` for these,
178    /// which is how a caller tells our bug from the CDN's.
179    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    /// Returns true for 404/410 (expired URL) — should trigger auth refresh like auth errors.
188    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
232/// Auth-refresh + host-failover retry loop that returns the decrypted bytes.
233/// Unlike [`download_to_writer_with_retry`] each attempt gets a FRESH buffer
234/// (the executor allocates its own), so a failed host that wrote a longer body
235/// (e.g. a CDN error page that decrypts to more bytes before its MAC fails)
236/// can't leave a stale tail behind a shorter successful retry.
237///
238/// `max_refresh_attempts` is 0 for a caller with no media conn to refresh: the
239/// URLs it would re-derive are the ones that just failed.
240async 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                    // Auth error or 404/410 (expired URL): refresh media conn and re-derive URLs.
277                    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                // The writer went into the failed executor and did not come back,
348                // so there is nothing left here to clean up.
349                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
391/// Fetch one prepared request into memory, decrypting as it goes when the HTTP
392/// client can stream and reusing the buffered response allocation when it can't.
393async 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
411/// Speculative capacity for one download attempt, from the declared plaintext
412/// length.
413fn download_capacity(downloadable: &dyn Downloadable) -> usize {
414    downloadable
415        .file_length()
416        .unwrap_or(0)
417        .min(DOWNLOAD_PREALLOC_CAP) as usize
418}
419
420/// Downloads media from the CDN with no connected [`Client`] behind it.
421///
422/// Everything a download needs beyond the CDN hosts already lives in the
423/// [`Downloadable`] itself, and the hosts are injected here rather than fetched,
424/// so persisted references stay usable after the session is gone. A live client
425/// keeps asking the server for its hosts; this is the path for callers that have
426/// no session to ask with.
427pub struct MediaDownloader {
428    http_client: Arc<dyn HttpClient>,
429    runtime: Arc<dyn Runtime>,
430    route: MediaRoute,
431}
432
433/// The refresh budget for a caller with no media conn behind it. Its
434/// counterpart is [`MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS`], which the `Client`
435/// paths pass.
436const 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    /// [`Self::new`] over [`MediaRoute::default_hosts`].
452    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    /// Mirrors [`Client::download`], minus the media-conn refresh: there is no
461    /// session to derive fresh auth from, so a rejected reference is terminal.
462    #[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    /// Mirrors [`Client::download_to_writer`], including its writer contract:
490    /// exactly the media on success, empty on failure.
491    #[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    /// Downloads and decrypts media from WhatsApp's CDN into memory.
523    ///
524    /// Only needed when you need the plaintext bytes (processing, transcoding,
525    /// re-upload). To forward existing media unchanged, reuse the original
526    /// message's CDN fields directly, no round-trip required.
527    #[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        // Each attempt owns a fresh buffer, so failed hosts cannot leave a stale
533        // tail behind a shorter retry. Streaming clients decrypt directly into a
534        // pre-sized output. Buffered clients already paid for a complete response
535        // Vec, so authenticate and decrypt that allocation in place instead of
536        // keeping a second file-sized output alive beside it.
537        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    /// Fetch a first-party sticker pack's metadata and sticker list from the CDN.
552    ///
553    /// Each returned [`wacore::sticker_pack::StickerPackItem`] is [`Downloadable`],
554    /// so individual stickers can be fetched with [`Self::download`]. The locale
555    /// only affects localized pack names; `"en"` mirrors whatsmeow's default.
556    #[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    /// Downloads and decrypts media from raw parameters without needing the original message.
585    #[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        // A static URL is fetched verbatim, so the media-conn IQ would be a round
596        // trip whose answer is discarded before a byte of it is read.
597        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    /// Downloads and decrypts media with streaming (constant memory usage).
606    ///
607    /// The entire HTTP download, decryption, and file write happen in a single
608    /// blocking thread. The writer is seeked back to position 0 before returning.
609    ///
610    /// On success the writer holds exactly the decrypted media and nothing else.
611    /// Every attempt starts by emptying it, so neither content the caller left
612    /// behind nor a host that streamed out plaintext before failing its MAC can
613    /// survive into the result. Providing that is what [`DownloadWriter`] is for,
614    /// and why this does not take a plain `Write + Seek`.
615    ///
616    /// On failure the writer is emptied too, on a best-effort basis: a sink that
617    /// refuses to empty is logged rather than replacing the download's own error,
618    /// and a writer lost to a panicking executor cannot be reached to be cleaned
619    /// at all. Both leave unverified bytes only in a sink the caller reaches
620    /// through a handle it kept, since this otherwise consumes the writer.
621    ///
622    /// Memory usage: ~40KB regardless of file size (8KB read buffer + decrypt state).
623    #[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    /// Streaming variant of `download_from_params` that writes to a writer
653    /// instead of buffering in memory.
654    #[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
664/// Empty a writer and rewind it, so whatever is written next is all it holds.
665///
666/// Emptying rather than only rewinding is what makes the length of a finished
667/// attempt knowable. It also keeps the guarantee independent of how the sink
668/// treats position: a [`std::fs::File`] opened for appending ignores seeks and
669/// writes at the end, so only a sink whose end has been brought back to zero
670/// puts an append-mode write where the media belongs.
671fn clear_writer<W: DownloadWriter>(writer: &mut W) -> std::io::Result<()> {
672    writer.truncate(0)?;
673    writer.rewind()?;
674    Ok(())
675}
676
677/// Rewind a verified attempt for the caller to read back.
678///
679/// No truncation here: the attempt began on a sink [`clear_writer`] had emptied,
680/// so the bytes it wrote are the only bytes present. That is what lets a host
681/// which streamed out plaintext before failing its MAC be replaced by a shorter
682/// one without leaving a tail behind the media.
683fn finish_verified_write<W: DownloadWriter>(
684    writer: &mut W,
685) -> std::result::Result<(), DownloadRequestError> {
686    writer.rewind().map_err(DownloadRequestError::other)
687}
688
689/// Empty a writer whose download is not coming back.
690///
691/// Runs on the blocking pool because it can reach a filesystem — or a
692/// third-party [`DownloadWriter`] — and this is the async retry future, which
693/// shares its runtime with the read loop.
694///
695/// Cleanup is best-effort: the caller is owed the failure that caused this, not
696/// an I/O error raised while tidying up after it. A sink that refuses to empty
697/// is logged, because the bytes it kept are unauthenticated and the caller has
698/// no other way to learn they are there.
699async 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
715/// Download + decrypt to a writer. Uses streaming when available,
716/// falls back to buffered otherwise. Returns writer for retry.
717async 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
776/// Buffered fallback when streaming is not available.
777async 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    // Keep authentication/decryption and writer I/O in one blocking task so
790    // non-streaming writer downloads pay for a single executor round-trip.
791    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
820/// Execute a non-streaming HTTP download and reuse the response allocation
821/// for the final plaintext. This is especially important on WASM, where the
822/// JS `Uint8Array` must already be copied into linear memory at the FFI edge.
823async 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    /// Answers a single request with `status` and `body`, then closes. Stands in
900    /// for one CDN host.
901    #[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    // Regression (#1185): `validate_download_status` was unit-tested while being
955    // unreachable — the HTTP client turned every non-2xx into a transport error,
956    // so a stale-auth 403 classified as `Other` and the whole host list was
957    // retried with the same dead token instead of refreshing the media conn.
958    // These two drive the real HTTP client against a real socket, so nothing
959    // between the CDN status and the classifier is stubbed out.
960    #[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    /// The chain the two tests above only prove one link of: a real CDN 403 must
999    /// reach `invalidate_media_conn()` and let the forced-refresh attempt
1000    /// succeed. Before the fix the 403 arrived as an opaque transport error, so
1001    /// this loop rotated hosts on the same dead auth token and never refreshed.
1002    #[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            // Mirrors `Client::download`'s executor: streaming into a fresh buffer.
1044            |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    /// Regression (#1193): the retry loop classified the CDN status correctly
1079    /// and then dropped the classification on the way out — `into_anyhow`
1080    /// unwrapped every variant to the same bare message, so a consumer of the
1081    /// public path could only recover the status by parsing `Display`.
1082    ///
1083    /// Driven through the real loop against a real socket rather than by
1084    /// building the error here: #1185 was a classifier that was unit-tested
1085    /// while unreachable, and a test that constructs its own error would repeat
1086    /// exactly that mistake.
1087    #[cfg(feature = "ureq-client")]
1088    #[tokio::test]
1089    async fn the_cdn_status_survives_to_the_public_error() {
1090        // One per branch of `validate_download_status`, since each built its
1091        // error by a different route: auth, not-found, and the `Other` arm that
1092        // the loop does not act on but a caller still has to tell apart.
1093        for (status, reason) in [
1094            (403u16, "Forbidden"),
1095            (410, "Gone"),
1096            (429, "Too Many Requests"),
1097        ] {
1098            // Each server answers once. 403 and 410 make the loop refresh the
1099            // media conn and try again, so the retry needs a host of its own —
1100            // still refusing, which is the case where the caller finally sees
1101            // the error.
1102            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            // Ends in `into_anyhow`, exactly as `Client::download` does — that
1107            // conversion is where the classification used to be lost.
1108            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            // The status stayed in the message too, so nothing that logs the
1148            // error today reads differently.
1149            assert!(
1150                format!("{err}").contains(&status.to_string()),
1151                "the message should still name the status, got: {err}"
1152            );
1153        }
1154    }
1155
1156    /// A failure with no HTTP status must not acquire one. That is the
1157    /// difference between "the CDN says this is gone" and "we broke", and a
1158    /// caller passing a status upstream needs it to be the CDN's.
1159    #[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        // Nothing is listening, so the exchange never completes.
1164        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    // `download()` uses `download_media_with_retry` (fresh buffer per attempt);
1269    // cover its auth-refresh + host-failover retry behavior directly.
1270    #[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    // A generic (non-auth, non-404) error on one host must fall through to the
1343    // next host within the SAME attempt — no media-conn refresh — and succeed.
1344    #[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        // Single attempt, no refresh: a generic error doesn't invalidate the media conn.
1409        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    // When every host fails with a generic error, the accumulated `last_err`
1418    // is surfaced (not the fallback "all hosts" message) and no refresh happens.
1419    #[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    // ── Session-less downloads ──────────────────────────────────────────────
1557
1558    /// Answers by first substring match on the requested URL, recording every
1559    /// URL it was asked for so host order and retry count are observable.
1560    struct RoutedHttpClient {
1561        routes: Vec<(&'static str, u16, Vec<u8>)>,
1562        fallback: (u16, Vec<u8>),
1563        streaming: bool,
1564        // std, not async: `execute_streaming` is a blocking call.
1565        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        // Implemented so `download_to_writer` exercises the streaming branch
1627        // rather than silently falling back to the buffered one.
1628        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    /// A body encrypted under `media_key` that decrypts to `plaintext` and only
1668    /// then fails its MAC — the shape a CDN error page takes on the wire once the
1669    /// reference's key is applied to it.
1670    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    /// Media is authenticated by one MAC over the whole ciphertext, so a failing
1681    /// host streams plaintext into the writer and only then turns out to be
1682    /// forged. If the retry that replaces it writes fewer bytes, whatever the
1683    /// first host left past that point must not survive into a successful
1684    /// download. Regression test for #1196.
1685    #[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        // Far longer than the real media, and longer than one 8KB decrypt chunk,
1702        // so the plaintext is already in the writer when the MAC check fails.
1703        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(&params, 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    /// The same guarantee from the other direction: content the caller left in
1739    /// the writer is not part of the media either.
1740    #[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(&params, Cursor::new(vec![0xFF; 4096]))
1748            .await
1749            .expect("download should succeed");
1750
1751        assert_eq!(writer.into_inner(), original);
1752    }
1753
1754    /// A file opened for appending writes at the end no matter where it was
1755    /// seeked, so rewinding alone would leave the media behind whatever the file
1756    /// already held. Emptying the sink is what puts an append-mode write at the
1757    /// start, and a real `File` is the only way to prove it — `Cursor` honours
1758    /// seeks and cannot express the mode.
1759    #[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(&params, 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    /// A [`DownloadWriter`] whose bytes outlive it.
1788    ///
1789    /// `download_to_writer` takes its writer by value and only hands it back on
1790    /// success, so a failed download's writer is otherwise unobservable. Being a
1791    /// third-party implementation, this also pins that the trait can be
1792    /// implemented from outside the crate.
1793    #[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    /// A download that never succeeds must not leave a half-written body behind
1833    /// for a caller to mistake for media.
1834    #[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(&params, 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(&params)
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(&params, 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        // Same request over a client without streaming, to keep the buffered
1908        // fallback covered as well.
1909        let buffered = RoutedHttpClient::new(Vec::new(), (200, encrypted));
1910        let writer = downloader(buffered, &["cdn.example.com"])
1911            .download_to_writer(&params, Cursor::new(Vec::new()))
1912            .await
1913            .expect("the buffered fallback must work too");
1914        assert_eq!(writer.into_inner(), original);
1915    }
1916
1917    // A reference too incomplete to build a URL from never contacts a host, so
1918    // it must not be reported as though every host had failed.
1919    #[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(&params)
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(&params)
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(&params)
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(&params)
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    // Regression: a `static_url` download used to fetch a media conn over the
2014    // wire and then throw it away, which also made the download impossible
2015    // offline. A disconnected client makes the discarded IQ observable.
2016    #[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        // The same client still asks the server for hosts when there is no
2037        // static URL, which is what makes the assertion above meaningful.
2038        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    /// HTTP client that records the requested URL and returns a canned response.
2054    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        // Same contract as the media paths: the status is recoverable by type,
2118        // not only readable in the message.
2119        let cause: &(dyn std::error::Error + 'static) = err.as_ref();
2120        assert_eq!(cause.http_status(), Some(404), "got: {err:?}");
2121    }
2122}