Skip to main content

whatsapp_rust/
upload.rs

1use anyhow::{Result, anyhow};
2use base64::Engine;
3use serde::Deserialize;
4use wacore::download::MediaType;
5use wacore::net::WHATSAPP_WEB_ORIGIN;
6use wacore::sync_marker::MaybeSend;
7
8use crate::client::Client;
9use crate::http::{HttpRequest, HttpResponse, HttpStatusError};
10use crate::mediaconn::{MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS, is_media_auth_error};
11
12/// Files >= 5 MiB check for existing/partial upload before sending.
13/// Matches WA Web's `_checkIfAlreadyUploaded` flow.
14const RESUMABLE_UPLOAD_THRESHOLD: usize = 5 * 1024 * 1024;
15
16/// Result of checking if an upload already exists on the server.
17enum UploadExistsResult {
18    /// Upload is complete — server already has the file.
19    Complete { url: String, direct_path: String },
20    /// Upload is partially done — resume from this byte offset.
21    Resume { byte_offset: u64 },
22    /// No previous upload found — start from scratch.
23    NotFound,
24}
25
26/// Server response for upload progress check (`?resume=1`).
27#[derive(Deserialize)]
28struct UploadProgressResponse {
29    #[serde(default)]
30    url: Option<String>,
31    #[serde(default)]
32    direct_path: Option<String>,
33    /// "complete" or a byte offset as string.
34    #[serde(default)]
35    resume: Option<String>,
36}
37
38/// Parse an upload progress response into an `UploadExistsResult`.
39fn parse_upload_progress(resp: &HttpResponse, total_size: u64) -> UploadExistsResult {
40    if resp.status_code >= 400 {
41        return UploadExistsResult::NotFound;
42    }
43    let Ok(progress) = serde_json::from_slice::<UploadProgressResponse>(&resp.body) else {
44        return UploadExistsResult::NotFound;
45    };
46    match progress.resume.as_deref() {
47        Some("complete") => {
48            if let (Some(url), Some(direct_path)) = (progress.url, progress.direct_path) {
49                UploadExistsResult::Complete { url, direct_path }
50            } else {
51                UploadExistsResult::NotFound
52            }
53        }
54        Some(offset_str) => match offset_str.parse::<u64>() {
55            Ok(offset) if offset > 0 && offset < total_size => UploadExistsResult::Resume {
56                byte_offset: offset,
57            },
58            _ => UploadExistsResult::NotFound,
59        },
60        _ => UploadExistsResult::NotFound,
61    }
62}
63
64/// URL + headers only; the body is supplied by `send_body`, so one retry/resume
65/// loop serves both the buffered and streaming paths.
66fn build_upload_request(
67    hostname: &str,
68    upload_path: &str,
69    auth: &str,
70    token: &str,
71    file_offset: Option<u64>,
72) -> HttpRequest {
73    let mut url = format!("https://{hostname}{upload_path}/{token}?auth={auth}&token={token}");
74    if let Some(offset) = file_offset {
75        url.push_str("&file_offset=");
76        url.push_str(itoa::Buffer::new().format(offset));
77    }
78
79    HttpRequest::post(url)
80        .with_header("Content-Type", "application/octet-stream")
81        .with_header("Origin", WHATSAPP_WEB_ORIGIN)
82}
83
84fn build_resume_check_request(
85    hostname: &str,
86    upload_path: &str,
87    auth: &str,
88    token: &str,
89) -> HttpRequest {
90    let url = format!("https://{hostname}{upload_path}/{token}?auth={auth}&token={token}&resume=1");
91    HttpRequest::post(url).with_header("Origin", WHATSAPP_WEB_ORIGIN)
92}
93
94fn upload_error_from_response(response: HttpResponse) -> anyhow::Error {
95    let status = response.status_code;
96    let context = match response.body_string() {
97        Ok(body) => format!("Upload failed {status} body={body}"),
98        Err(body_err) => {
99            format!("Upload failed {status} and failed to read response body: {body_err}")
100        }
101    };
102    // The status also goes in as a typed node, not only into the message: the
103    // host-rotation loop below reads it via `is_media_auth_error`, and the
104    // caller that ends up with `last_error` needs the same answer.
105    HttpStatusError { status }.into_error(context)
106}
107
108/// Crypto metadata needed to finalize an upload, independent of how the
109/// encrypted body is transmitted (buffered or streamed).
110struct UploadCrypto {
111    media_key: [u8; 32],
112    file_sha256: [u8; 32],
113    file_enc_sha256: [u8; 32],
114    streaming_sidecar: Option<Vec<u8>>,
115}
116
117impl UploadCrypto {
118    fn response(
119        &self,
120        url: String,
121        direct_path: String,
122        file_length: u64,
123        media_key_timestamp: i64,
124    ) -> UploadResponse {
125        UploadResponse {
126            url,
127            direct_path,
128            media_key: self.media_key,
129            file_enc_sha256: self.file_enc_sha256,
130            file_sha256: self.file_sha256,
131            file_length,
132            media_key_timestamp,
133            streaming_sidecar: self.streaming_sidecar.clone(),
134        }
135    }
136}
137
138/// Boxed future for the dyn-driven retry loop below. `Send` keeps the upload
139/// futures spawnable on native; on wasm the `HttpClient` futures are `?Send`
140/// (single-threaded runtime), so the bound is dropped there.
141#[cfg(not(target_arch = "wasm32"))]
142type BoxFut<'a, T> = std::pin::Pin<Box<dyn Future<Output = T> + Send + 'a>>;
143#[cfg(target_arch = "wasm32")]
144type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>;
145
146// Only auto traits can join a `dyn Fn` bound, so the wasm variants must spell
147// out the whole alias instead of borrowing `MaybeSend`.
148#[cfg(not(target_arch = "wasm32"))]
149mod dyn_callbacks {
150    use super::*;
151    pub(super) type GetMediaConnDyn<'a> =
152        dyn FnMut(bool) -> BoxFut<'a, Result<crate::mediaconn::MediaConn>> + Send + 'a;
153    pub(super) type InvalidateMediaConnDyn<'a> = dyn FnMut() -> BoxFut<'a, ()> + Send + 'a;
154    pub(super) type ExecuteRequestDyn<'a> =
155        dyn FnMut(HttpRequest) -> BoxFut<'a, Result<HttpResponse>> + Send + 'a;
156    pub(super) type SendBodyDyn<'a> =
157        dyn FnMut(HttpRequest, u64, u64) -> BoxFut<'a, Result<HttpResponse>> + Send + 'a;
158}
159#[cfg(target_arch = "wasm32")]
160mod dyn_callbacks {
161    use super::*;
162    pub(super) type GetMediaConnDyn<'a> =
163        dyn FnMut(bool) -> BoxFut<'a, Result<crate::mediaconn::MediaConn>> + 'a;
164    pub(super) type InvalidateMediaConnDyn<'a> = dyn FnMut() -> BoxFut<'a, ()> + 'a;
165    pub(super) type ExecuteRequestDyn<'a> =
166        dyn FnMut(HttpRequest) -> BoxFut<'a, Result<HttpResponse>> + 'a;
167    pub(super) type SendBodyDyn<'a> =
168        dyn FnMut(HttpRequest, u64, u64) -> BoxFut<'a, Result<HttpResponse>> + 'a;
169}
170use dyn_callbacks::*;
171
172/// Drives host failover, auth refresh, and resumable upload. `file_length` is the
173/// plaintext size (for the response); `ciphertext_len` is the encrypted blob size
174/// (for the resume threshold/offsets). `send_body` transmits the body from a given
175/// offset; `execute_request` serves the body-less resume check.
176///
177/// Thin adapter: boxes the per-call futures so the large driver body
178/// instantiates once instead of per closure combination.
179#[allow(clippy::too_many_arguments)]
180async fn upload_media_with_retry<GMC, GMCFut, IMC, IMCFut, EXR, EXRFut, SB, SBFut>(
181    crypto: UploadCrypto,
182    media_type: MediaType,
183    file_length: u64,
184    ciphertext_len: u64,
185    media_key_timestamp: i64,
186    mut get_media_conn: GMC,
187    mut invalidate_media_conn: IMC,
188    mut execute_request: EXR,
189    mut send_body: SB,
190) -> Result<UploadResponse>
191where
192    GMC: FnMut(bool) -> GMCFut + MaybeSend,
193    GMCFut: Future<Output = Result<crate::mediaconn::MediaConn>> + MaybeSend,
194    IMC: FnMut() -> IMCFut + MaybeSend,
195    IMCFut: Future<Output = ()> + MaybeSend,
196    EXR: FnMut(HttpRequest) -> EXRFut + MaybeSend,
197    EXRFut: Future<Output = Result<HttpResponse>> + MaybeSend,
198    SB: FnMut(HttpRequest, u64, u64) -> SBFut + MaybeSend,
199    SBFut: Future<Output = Result<HttpResponse>> + MaybeSend,
200{
201    upload_media_with_retry_dyn(
202        crypto,
203        media_type,
204        file_length,
205        ciphertext_len,
206        media_key_timestamp,
207        &mut |force| Box::pin(get_media_conn(force)),
208        &mut || Box::pin(invalidate_media_conn()),
209        &mut |request| Box::pin(execute_request(request)),
210        &mut |request, offset, remaining| Box::pin(send_body(request, offset, remaining)),
211    )
212    .await
213}
214
215#[allow(clippy::too_many_arguments)]
216async fn upload_media_with_retry_dyn<'a>(
217    crypto: UploadCrypto,
218    media_type: MediaType,
219    file_length: u64,
220    ciphertext_len: u64,
221    media_key_timestamp: i64,
222    get_media_conn: &mut GetMediaConnDyn<'a>,
223    invalidate_media_conn: &mut InvalidateMediaConnDyn<'a>,
224    execute_request: &mut ExecuteRequestDyn<'a>,
225    send_body: &mut SendBodyDyn<'a>,
226) -> Result<UploadResponse> {
227    let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(crypto.file_enc_sha256);
228    let upload_path = media_type.upload_path();
229    let mut force_refresh = false;
230    let mut last_error: Option<anyhow::Error> = None;
231
232    for attempt in 0..=MEDIA_AUTH_REFRESH_RETRY_ATTEMPTS {
233        let media_conn = get_media_conn(force_refresh).await?;
234        if media_conn.hosts.is_empty() {
235            return Err(anyhow!("No media hosts"));
236        }
237
238        let mut retry_with_fresh_auth = false;
239
240        for host in &media_conn.hosts {
241            let mut offset: u64 = 0;
242            let mut file_offset: Option<u64> = None;
243
244            // For large files, check whether the upload already exists or can be
245            // resumed. Matches WA Web's _checkIfAlreadyUploaded flow.
246            if ciphertext_len >= RESUMABLE_UPLOAD_THRESHOLD as u64 {
247                let check_req = build_resume_check_request(
248                    &host.hostname,
249                    upload_path,
250                    &media_conn.auth,
251                    &token,
252                );
253                if let Ok(check_resp) = execute_request(check_req).await {
254                    match parse_upload_progress(&check_resp, ciphertext_len) {
255                        UploadExistsResult::Complete { url, direct_path } => {
256                            return Ok(crypto.response(
257                                url,
258                                direct_path,
259                                file_length,
260                                media_key_timestamp,
261                            ));
262                        }
263                        UploadExistsResult::Resume { byte_offset } => {
264                            if byte_offset >= ciphertext_len {
265                                log::warn!(
266                                    "Server resume offset {byte_offset} exceeds data length {ciphertext_len}; uploading from start"
267                                );
268                            } else {
269                                log::info!(
270                                    "Resuming upload from byte {byte_offset}/{ciphertext_len}"
271                                );
272                                offset = byte_offset;
273                                file_offset = Some(byte_offset);
274                            }
275                        }
276                        UploadExistsResult::NotFound => {}
277                    }
278                }
279                // Non-fatal: if the check request itself fails, proceed with full upload.
280            }
281
282            let request = build_upload_request(
283                &host.hostname,
284                upload_path,
285                &media_conn.auth,
286                &token,
287                file_offset,
288            );
289
290            let response = match send_body(request, offset, ciphertext_len - offset).await {
291                Ok(response) => response,
292                Err(err) => {
293                    last_error = Some(err);
294                    continue;
295                }
296            };
297
298            if response.status_code < 400 {
299                let raw: RawUploadResponse = serde_json::from_slice(&response.body)?;
300                return Ok(crypto.response(
301                    raw.url,
302                    raw.direct_path,
303                    file_length,
304                    media_key_timestamp,
305                ));
306            }
307
308            let status_code = response.status_code;
309            let err = upload_error_from_response(response);
310
311            if is_media_auth_error(status_code) {
312                if attempt == 0 {
313                    invalidate_media_conn().await;
314                    force_refresh = true;
315                    retry_with_fresh_auth = true;
316                    break;
317                }
318
319                return Err(err);
320            }
321
322            last_error = Some(err);
323        }
324
325        if !retry_with_fresh_auth {
326            break;
327        }
328    }
329
330    Err(last_error.unwrap_or_else(|| anyhow!("Failed to upload to all available media hosts")))
331}
332
333#[derive(Debug, Clone)]
334#[non_exhaustive]
335pub struct UploadResponse {
336    pub url: String,
337    pub direct_path: String,
338    pub media_key: [u8; 32],
339    pub file_enc_sha256: [u8; 32],
340    pub file_sha256: [u8; 32],
341    pub file_length: u64,
342    /// Unix timestamp (seconds) when the media key was generated.
343    pub media_key_timestamp: i64,
344    /// Per-64-KiB HMAC table for progressive playback/seek (audio/video only);
345    /// pass to `AudioMessage`/`VideoMessage.streaming_sidecar`.
346    pub streaming_sidecar: Option<Vec<u8>>,
347}
348
349impl From<UploadResponse> for wacore::sticker_pack::MediaUploadInfo {
350    fn from(r: UploadResponse) -> Self {
351        Self::new(
352            r.direct_path,
353            r.media_key,
354            r.file_sha256,
355            r.file_enc_sha256,
356            r.file_length,
357            r.media_key_timestamp,
358        )
359    }
360}
361
362#[derive(Deserialize)]
363struct RawUploadResponse {
364    url: String,
365    direct_path: String,
366}
367
368#[non_exhaustive]
369#[derive(Default, Clone)]
370pub struct UploadOptions {
371    /// Reuse an existing media key instead of generating a fresh one.
372    pub media_key: Option<[u8; 32]>,
373    /// Override streaming-sidecar generation; `None` selects it by media type
374    /// (audio/video only).
375    pub streaming_sidecar: Option<bool>,
376}
377
378impl std::fmt::Debug for UploadOptions {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        f.debug_struct("UploadOptions")
381            .field("media_key", &self.media_key.as_ref().map(|_| "<redacted>"))
382            .field("streaming_sidecar", &self.streaming_sidecar)
383            .finish()
384    }
385}
386
387impl UploadOptions {
388    pub fn new() -> Self {
389        Self::default()
390    }
391
392    pub fn with_media_key(mut self, key: [u8; 32]) -> Self {
393        self.media_key = Some(key);
394        self
395    }
396
397    pub fn with_streaming_sidecar(mut self, enabled: bool) -> Self {
398        self.streaming_sidecar = Some(enabled);
399        self
400    }
401}
402
403impl Client {
404    /// Encrypts and uploads media to WhatsApp's CDN.
405    ///
406    /// Only needed for new or modified media. To forward existing media unchanged,
407    /// reuse the original message's CDN fields directly, no round-trip required.
408    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.upload", level = "debug", skip_all, fields(kind = ?media_type, len = data.len()), err(Debug)))]
409    pub async fn upload(
410        &self,
411        data: Vec<u8>,
412        media_type: MediaType,
413        options: UploadOptions,
414    ) -> Result<UploadResponse> {
415        let file_length = data.len() as u64;
416        let media_key = options.media_key;
417        let sidecar = options.streaming_sidecar;
418        let enc = wacore::runtime::blocking(&*self.runtime, move || {
419            wacore::upload::encrypt_media_with_key_and_sidecar(
420                &data,
421                media_type,
422                media_key.as_ref(),
423                sidecar,
424            )
425        })
426        .await?;
427
428        let ciphertext_len = enc.data_to_upload.len() as u64;
429        let crypto = UploadCrypto {
430            media_key: enc.media_key,
431            file_sha256: enc.file_sha256,
432            file_enc_sha256: enc.file_enc_sha256,
433            streaming_sidecar: enc.streaming_sidecar,
434        };
435        // Bytes so each retry/resume attempt slices with a refcount bump instead of
436        // copying the whole (multi-MB) ciphertext per attempt.
437        let ciphertext = bytes::Bytes::from(enc.data_to_upload);
438
439        upload_media_with_retry(
440            crypto,
441            media_type,
442            file_length,
443            ciphertext_len,
444            wacore::time::now_secs(),
445            |force| async move { self.refresh_media_conn(force).await.map_err(Into::into) },
446            || async { self.invalidate_media_conn().await },
447            |request| async move { self.http_client.execute(request).await },
448            |request, offset, _remaining| {
449                let body = ciphertext.slice(offset as usize..);
450                async move { self.http_client.execute(request.with_body(body)).await }
451            },
452        )
453        .await
454    }
455
456    /// Uploads already-encrypted media streamed from `source`, keeping memory
457    /// constant regardless of file size. Encrypt the plaintext first with
458    /// [`wacore::upload::encrypt_media_streaming`] (or `..._with_key`) into the
459    /// storage of your choice, then pass that storage as `source` plus the
460    /// returned [`wacore::upload::EncryptedMediaInfo`]. The caller owns where the
461    /// ciphertext lives (temp file, memory, …); this method never touches disk.
462    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.upload_stream", level = "debug", skip_all, fields(kind = ?media_type), err(Debug)))]
463    pub async fn upload_stream<S>(
464        &self,
465        source: S,
466        info: wacore::upload::EncryptedMediaInfo,
467        media_type: MediaType,
468    ) -> Result<UploadResponse>
469    where
470        S: wacore::upload::UploadSource + 'static,
471    {
472        let file_length = info.file_length;
473        let ciphertext_len = source.len();
474        let crypto = UploadCrypto {
475            media_key: info.media_key,
476            file_sha256: info.file_sha256,
477            file_enc_sha256: info.file_enc_sha256,
478            streaming_sidecar: info.streaming_sidecar,
479        };
480        let source = std::sync::Arc::new(source);
481
482        upload_media_with_retry(
483            crypto,
484            media_type,
485            file_length,
486            ciphertext_len,
487            wacore::time::now_secs(),
488            |force| async move { self.refresh_media_conn(force).await.map_err(Into::into) },
489            || async { self.invalidate_media_conn().await },
490            |request| async move { self.http_client.execute(request).await },
491            |request, offset, remaining| {
492                let source = std::sync::Arc::clone(&source);
493                let http = std::sync::Arc::clone(&self.http_client);
494                async move {
495                    // reader_from may open/seek a file, so run it in the blocking
496                    // task with execute_upload, not on the async worker.
497                    wacore::runtime::blocking(&*self.runtime, move || {
498                        let reader = source.reader_from(offset)?;
499                        http.execute_upload(request, reader, remaining)
500                    })
501                    .await
502                }
503            },
504        )
505        .await
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::mediaconn::{MediaConn, MediaConnHost};
513    use async_lock::Mutex;
514    use std::sync::Arc;
515    use wacore::time::Instant;
516
517    fn media_conn(auth: &str, hosts: &[&str]) -> MediaConn {
518        MediaConn {
519            auth: auth.to_string(),
520            ttl: 60,
521            auth_ttl: None,
522            hosts: hosts
523                .iter()
524                .map(|hostname| MediaConnHost::new((*hostname).to_string()))
525                .collect(),
526            fetched_at: Instant::now(),
527        }
528    }
529
530    fn crypto_from(enc: &wacore::upload::EncryptedMedia) -> UploadCrypto {
531        UploadCrypto {
532            media_key: enc.media_key,
533            file_sha256: enc.file_sha256,
534            file_enc_sha256: enc.file_enc_sha256,
535            streaming_sidecar: enc.streaming_sidecar.clone(),
536        }
537    }
538
539    fn ok_json(url: &str, direct_path: &str) -> HttpResponse {
540        HttpResponse {
541            status_code: 200,
542            body: format!(r#"{{"url":"{url}","direct_path":"{direct_path}"}}"#).into_bytes(),
543        }
544    }
545
546    /// Resume check is skipped below the 5 MiB threshold, so a check call here
547    /// would be a logic error.
548    async fn unreachable_check(_req: HttpRequest) -> Result<HttpResponse> {
549        panic!("resume check must not run below the resumable threshold")
550    }
551
552    #[tokio::test]
553    async fn upload_retries_with_forced_media_conn_refresh_after_auth_error() {
554        let enc = wacore::upload::encrypt_media(b"retry me", MediaType::Image)
555            .expect("encryption should succeed");
556        let len = enc.data_to_upload.len() as u64;
557        let first_conn = media_conn("stale-auth", &["cdn1.example.com"]);
558        let refreshed_conn = media_conn("fresh-auth", &["cdn2.example.com"]);
559        let refresh_calls = Arc::new(Mutex::new(Vec::new()));
560        let invalidations = Arc::new(Mutex::new(0usize));
561        let seen_urls = Arc::new(Mutex::new(Vec::new()));
562
563        let result = upload_media_with_retry(
564            crypto_from(&enc),
565            MediaType::Image,
566            8,
567            len,
568            0,
569            {
570                let refresh_calls = Arc::clone(&refresh_calls);
571                move |force| {
572                    let refresh_calls = Arc::clone(&refresh_calls);
573                    let first_conn = first_conn.clone();
574                    let refreshed_conn = refreshed_conn.clone();
575                    async move {
576                        refresh_calls.lock().await.push(force);
577                        Ok(if force { refreshed_conn } else { first_conn })
578                    }
579                }
580            },
581            {
582                let invalidations = Arc::clone(&invalidations);
583                move || {
584                    let invalidations = Arc::clone(&invalidations);
585                    async move {
586                        *invalidations.lock().await += 1;
587                    }
588                }
589            },
590            unreachable_check,
591            {
592                let seen_urls = Arc::clone(&seen_urls);
593                move |request: HttpRequest, _offset, _remaining| {
594                    let seen_urls = Arc::clone(&seen_urls);
595                    async move {
596                        seen_urls.lock().await.push(request.url.clone());
597                        if request.url.contains("stale-auth") {
598                            Ok(HttpResponse {
599                                status_code: 401,
600                                body: b"expired".to_vec(),
601                            })
602                        } else {
603                            Ok(ok_json(
604                                "https://cdn2.example.com/file",
605                                "/v/t62.7118-24/123",
606                            ))
607                        }
608                    }
609                }
610            },
611        )
612        .await
613        .expect("upload should succeed after refreshing media auth");
614
615        assert_eq!(*refresh_calls.lock().await, vec![false, true]);
616        assert_eq!(*invalidations.lock().await, 1);
617
618        let seen_urls = seen_urls.lock().await.clone();
619        assert_eq!(seen_urls.len(), 2);
620        assert!(seen_urls[0].contains("cdn1.example.com"));
621        assert!(seen_urls[0].contains("auth=stale-auth"));
622        assert!(seen_urls[1].contains("cdn2.example.com"));
623        assert!(seen_urls[1].contains("auth=fresh-auth"));
624        assert_eq!(result.direct_path, "/v/t62.7118-24/123");
625        assert_eq!(result.url, "https://cdn2.example.com/file");
626        assert_eq!(result.media_key_timestamp, 0);
627    }
628
629    #[tokio::test]
630    async fn upload_fails_over_to_next_host_after_non_auth_error() {
631        let enc = wacore::upload::encrypt_media(b"retry host", MediaType::Image)
632            .expect("encryption should succeed");
633        let len = enc.data_to_upload.len() as u64;
634        let conn = media_conn("shared-auth", &["cdn1.example.com", "cdn2.example.com"]);
635        let seen_urls = Arc::new(Mutex::new(Vec::new()));
636
637        let result = upload_media_with_retry(
638            crypto_from(&enc),
639            MediaType::Image,
640            10,
641            len,
642            0,
643            move |_force| {
644                let conn = conn.clone();
645                async move { Ok(conn) }
646            },
647            || async {},
648            unreachable_check,
649            {
650                let seen_urls = Arc::clone(&seen_urls);
651                move |request: HttpRequest, _offset, _remaining| {
652                    let seen_urls = Arc::clone(&seen_urls);
653                    async move {
654                        seen_urls.lock().await.push(request.url.clone());
655                        if request.url.contains("cdn1.example.com") {
656                            Ok(HttpResponse {
657                                status_code: 500,
658                                body: b"try another host".to_vec(),
659                            })
660                        } else {
661                            Ok(ok_json(
662                                "https://cdn2.example.com/file",
663                                "/v/t62.7118-24/456",
664                            ))
665                        }
666                    }
667                }
668            },
669        )
670        .await
671        .expect("upload should succeed on the second host");
672
673        let seen_urls = seen_urls.lock().await.clone();
674        assert_eq!(seen_urls.len(), 2);
675        assert!(seen_urls[0].contains("cdn1.example.com"));
676        assert!(seen_urls[1].contains("cdn2.example.com"));
677        assert_eq!(result.direct_path, "/v/t62.7118-24/456");
678        assert_eq!(result.media_key_timestamp, 0);
679    }
680
681    /// Regression (#1193): upload put the host's status only into a formatted
682    /// message, so a caller that ran out of hosts got "Upload failed 507 …" as
683    /// text and no way to tell a throttle from a broken upstream by type.
684    ///
685    /// Every host refuses here, which is the case whose error actually reaches
686    /// the caller — the failover tests above only cover the ones it recovers
687    /// from.
688    #[tokio::test]
689    async fn the_upload_status_survives_to_the_public_error() {
690        use crate::error::ErrorChainExt;
691
692        let enc = wacore::upload::encrypt_media(b"nowhere to go", MediaType::Image)
693            .expect("encryption should succeed");
694        let len = enc.data_to_upload.len() as u64;
695        let conn = media_conn("shared-auth", &["cdn1.example.com", "cdn2.example.com"]);
696
697        let err = upload_media_with_retry(
698            crypto_from(&enc),
699            MediaType::Image,
700            10,
701            len,
702            0,
703            move |_force| {
704                let conn = conn.clone();
705                async move { Ok(conn) }
706            },
707            || async {},
708            unreachable_check,
709            move |_request: HttpRequest, _offset, _remaining| async move {
710                Ok(HttpResponse {
711                    status_code: 507,
712                    body: b"insufficient storage".to_vec(),
713                })
714            },
715        )
716        .await
717        .expect_err("every host refusing must fail the upload");
718
719        let cause: &(dyn std::error::Error + 'static) = err.as_ref();
720        assert_eq!(
721            cause.http_status(),
722            Some(507),
723            "the consumer must recover the host's status by type, got: {err:?}"
724        );
725        assert!(
726            format!("{err}").contains("507"),
727            "the message should still name the status, got: {err}"
728        );
729    }
730
731    /// Above the threshold, a server `resume=<offset>` must drive `send_body` to
732    /// upload only the tail (`file_offset` in the URL, `remaining = len - offset`).
733    #[tokio::test]
734    async fn resumable_upload_sends_tail_from_offset() {
735        let total: u64 = 6 * 1024 * 1024;
736        let offset: u64 = 1024 * 1024;
737        let conn = media_conn("auth", &["cdn1.example.com"]);
738        let captured: Arc<Mutex<Option<(String, u64, u64)>>> = Arc::new(Mutex::new(None));
739
740        let crypto = UploadCrypto {
741            media_key: [0u8; 32],
742            file_sha256: [0u8; 32],
743            file_enc_sha256: [9u8; 32],
744            streaming_sidecar: Some(vec![1, 2, 3]),
745        };
746
747        let result = upload_media_with_retry(
748            crypto,
749            MediaType::Video,
750            total,
751            total,
752            0,
753            move |_force| {
754                let conn = conn.clone();
755                async move { Ok(conn) }
756            },
757            || async {},
758            move |_req| async move {
759                Ok(HttpResponse {
760                    status_code: 200,
761                    body: format!(r#"{{"resume":"{offset}"}}"#).into_bytes(),
762                })
763            },
764            {
765                let captured = Arc::clone(&captured);
766                move |request: HttpRequest, off, remaining| {
767                    let captured = Arc::clone(&captured);
768                    async move {
769                        *captured.lock().await = Some((request.url.clone(), off, remaining));
770                        Ok(ok_json("https://cdn1.example.com/f", "/dp"))
771                    }
772                }
773            },
774        )
775        .await
776        .expect("resumable upload should succeed");
777
778        let (url, sent_offset, remaining) = captured.lock().await.clone().unwrap();
779        assert_eq!(sent_offset, offset);
780        assert_eq!(remaining, total - offset);
781        assert!(url.contains(&format!("file_offset={offset}")), "url: {url}");
782        // Sidecar must survive into the response.
783        assert_eq!(result.streaming_sidecar.as_deref(), Some(&[1u8, 2, 3][..]));
784    }
785
786    /// `UploadSource` over `Arc<[u8]>` must report the length and re-read the
787    /// tail from any offset, repeatably.
788    #[test]
789    fn arc_upload_source_reads_from_offset_repeatably() {
790        use std::io::Read;
791        use wacore::upload::UploadSource;
792
793        let bytes: Arc<[u8]> = (0u8..200).collect::<Vec<_>>().into();
794        assert_eq!(UploadSource::len(&bytes), 200);
795
796        let read_at = |offset: u64| {
797            let mut r = bytes.reader_from(offset).unwrap();
798            let mut out = Vec::new();
799            r.read_to_end(&mut out).unwrap();
800            out
801        };
802
803        assert_eq!(read_at(0), (0u8..200).collect::<Vec<_>>());
804        assert_eq!(read_at(50), (50u8..200).collect::<Vec<_>>());
805        // Independent, repeatable readers (host failover / auth refresh).
806        assert_eq!(read_at(50), read_at(50));
807        assert_eq!(read_at(200), Vec::<u8>::new());
808    }
809}