Skip to main content

vector_core/
blossom.rs

1use crate::signer::VectorSigner;
2use crate::blossom_error::{host_of, summarise_failures, Refusal, UploadFailure};
3use nostr_sdk::prelude::{Event, FinalizeEventAsync, Timestamp, Url};
4use bitcoin_hashes::sha256::Hash as Sha256Hash;
5use nostr_blossom::prelude::*;
6use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
7use reqwest::{Body, StatusCode};
8use std::str::FromStr;
9use std::sync::{Arc, Mutex};
10use std::sync::atomic::{AtomicBool, Ordering};
11use futures_util::Stream;
12use std::pin::Pin;
13use std::task::{Context, Poll};
14
15/// Progress callback function type
16pub type ProgressCallback = std::sync::Arc<dyn Fn(Option<u8>, Option<u64>) -> Result<(), String> + Send + Sync>;
17
18/// The upload body: slices of the one ciphertext buffer, counted as the socket pulls
19/// them. A slice shares the buffer, so nothing is copied on the way out.
20#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
21struct ProgressTrackingStream {
22    data: bytes::Bytes,
23    position: usize,
24    bytes_sent: Arc<Mutex<u64>>,
25}
26
27/// Lends an `Arc`'d buffer to `Bytes` without copying it.
28#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
29struct SharedBuffer(Arc<Vec<u8>>);
30
31impl AsRef<[u8]> for SharedBuffer {
32    fn as_ref(&self) -> &[u8] {
33        &self.0
34    }
35}
36
37#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
38impl ProgressTrackingStream {
39    /// Progress is observed per slice pulled, so the slice is the slowest rate that
40    /// still reads as progress: 16 KB in a 60 s stall window is about 2 kbit/s.
41    const SLICE: usize = 16 * 1024;
42
43    fn new(data: Arc<Vec<u8>>, bytes_sent: Arc<Mutex<u64>>) -> Self {
44        Self { data: bytes::Bytes::from_owner(SharedBuffer(data)), position: 0, bytes_sent }
45    }
46}
47
48impl Stream for ProgressTrackingStream {
49    type Item = Result<bytes::Bytes, std::io::Error>;
50
51    fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
52        if self.position >= self.data.len() {
53            return Poll::Ready(None);
54        }
55        let end = (self.position + Self::SLICE).min(self.data.len());
56        let slice = self.data.slice(self.position..end);
57        self.position = end;
58        *self.bytes_sent.lock().unwrap() += slice.len() as u64;
59        Poll::Ready(Some(Ok(slice)))
60    }
61}
62
63/// What an upload sends: bytes in hand, or a file read from disk as the socket
64/// takes it, so a blob of any size costs one slice of memory.
65#[derive(Clone)]
66pub enum UploadBody {
67    Memory(Arc<Vec<u8>>),
68    /// A file whose length and SHA-256 are already known: the auth event and the
69    /// preflight need the hash before the first byte goes.
70    File { path: std::path::PathBuf, len: u64, sha256: Sha256Hash },
71}
72
73impl UploadBody {
74    /// A file body from its path, length and hex SHA-256.
75    pub fn file(path: std::path::PathBuf, len: u64, sha256_hex: &str) -> Result<Self, String> {
76        let sha256 = Sha256Hash::from_str(sha256_hex).map_err(|e| format!("bad blob hash: {e}"))?;
77        Ok(Self::File { path, len, sha256 })
78    }
79
80    pub fn len(&self) -> u64 {
81        match self {
82            Self::Memory(data) => data.len() as u64,
83            Self::File { len, .. } => *len,
84        }
85    }
86
87    pub fn is_empty(&self) -> bool {
88        self.len() == 0
89    }
90
91    fn sha256(&self) -> Sha256Hash {
92        match self {
93            Self::Memory(data) => Sha256Hash::hash(data),
94            Self::File { sha256, .. } => *sha256,
95        }
96    }
97
98    /// The body as a stream, counting into `bytes_sent` as slices are pulled.
99    #[cfg(not(target_arch = "wasm32"))]
100    fn stream(&self, bytes_sent: Arc<Mutex<u64>>) -> Pin<Box<dyn Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>> {
101        match self {
102            Self::Memory(data) => Box::pin(ProgressTrackingStream::new(Arc::clone(data), bytes_sent)),
103            Self::File { path, len, .. } => {
104                // Large: every read is a trip to the blocking pool, and each slice is handed
105                // to the body whole, so fewer, bigger ones (about a thousand per GB).
106                const FILE_SLICE: u64 = 1024 * 1024;
107                let start = (None::<tokio::fs::File>, path.clone(), *len);
108                Box::pin(futures_util::stream::unfold(start, move |(file, path, remaining)| {
109                    let bytes_sent = Arc::clone(&bytes_sent);
110                    async move {
111                        use tokio::io::AsyncReadExt;
112                        if remaining == 0 {
113                            return None;
114                        }
115                        let mut file = match file {
116                            Some(f) => f,
117                            None => match tokio::fs::File::open(&path).await {
118                                Ok(f) => f,
119                                Err(e) => return Some((Err(e), (None, path, 0))),
120                            },
121                        };
122                        let mut slice = vec![0u8; remaining.min(FILE_SLICE) as usize];
123                        match file.read_exact(&mut slice).await {
124                            Ok(_) => {
125                                *bytes_sent.lock().unwrap() += slice.len() as u64;
126                                let left = remaining - slice.len() as u64;
127                                Some((Ok(bytes::Bytes::from(slice)), (Some(file), path, left)))
128                            }
129                            Err(e) => Some((Err(e), (None, path, 0))),
130                        }
131                    }
132                }))
133            }
134        }
135    }
136}
137
138/// After the last byte is handed to the socket there is nothing left to watch:
139/// what remains is buffered bytes draining, an ingress relay forwarding the
140/// blob to its origin, and the origin storing it — none of which is visible
141/// from here. This bounds that wait.
142///
143/// Five minutes because the relay hop is real work: a gigabyte across a
144/// datacenter backhaul at the ~6 MB/s a single stream manages is nearly three
145/// of them, and giving up then would discard an upload that had in fact
146/// arrived.
147const RESPONSE_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
148
149/// Why an upload was abandoned.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum Stall {
152    /// The socket accepted nothing for the whole stall window.
153    NoProgress { idle: std::time::Duration },
154    /// Every byte was handed over and the server never answered.
155    NoResponse { waited: std::time::Duration },
156}
157
158/// Progress-based watchdog for an upload: gives up on a transfer that stops
159/// moving, never on one that is merely slow.
160struct StallWatch {
161    total: u64,
162    last_bytes: u64,
163    last_progress: crate::rt::time::Instant,
164    stall_limit: std::time::Duration,
165    response_limit: std::time::Duration,
166}
167
168impl StallWatch {
169    fn new(total: u64, stall_limit: std::time::Duration, response_limit: std::time::Duration) -> Self {
170        Self {
171            total,
172            last_bytes: 0,
173            last_progress: crate::rt::time::Instant::now(),
174            stall_limit,
175            response_limit,
176        }
177    }
178
179    /// Feed the latest byte count; `Some` means give up.
180    fn observe(&mut self, bytes_sent: u64) -> Option<Stall> {
181        let now = crate::rt::time::Instant::now();
182        if bytes_sent > self.last_bytes {
183            self.last_bytes = bytes_sent;
184            self.last_progress = now;
185            return None;
186        }
187        let idle = now.duration_since(self.last_progress);
188        if bytes_sent >= self.total {
189            (idle > self.response_limit).then_some(Stall::NoResponse { waited: idle })
190        } else {
191            (idle > self.stall_limit).then_some(Stall::NoProgress { idle })
192        }
193    }
194}
195
196/// A progress callback's refusal, as an upload failure: it says "Upload
197/// cancelled" when the user pressed stop, and that must stay a cancellation
198/// rather than an error.
199fn callback_failure(e: String) -> UploadFailure {
200    if e == "Upload cancelled" {
201        UploadFailure::Cancelled
202    } else {
203        UploadFailure::Other(e)
204    }
205}
206
207/// Send `body` as the body of `request`, watching progress rather than
208/// the clock.
209///
210/// There is no total time limit. A transfer moving at any rate runs to
211/// completion; one that stops for `stall_limit` is abandoned, as is one whose
212/// server goes silent after receiving everything.
213async fn send_upload(
214    request: reqwest::RequestBuilder,
215    server_url: &Url,
216    body: &UploadBody,
217    stall_limit: std::time::Duration,
218    cancel_flag: Option<&Arc<AtomicBool>>,
219    progress_callback: Option<&ProgressCallback>,
220) -> Result<reqwest::Response, UploadFailure> {
221    let total_size = body.len();
222    let bytes_sent = Arc::new(Mutex::new(0u64));
223    #[cfg(not(target_arch = "wasm32"))]
224    let request_body = Body::wrap_stream(body.stream(Arc::clone(&bytes_sent)));
225    // fetch cannot stream a request body, so the web sends it whole.
226    #[cfg(target_arch = "wasm32")]
227    let request_body = match body {
228        UploadBody::Memory(data) => {
229            *bytes_sent.lock().unwrap() = data.len() as u64;
230            Body::from(data.as_ref().clone())
231        }
232        UploadBody::File { .. } => return Err(UploadFailure::Transport("no filesystem on the web".into())),
233    };
234    let mut request_future = Box::pin(request.body(request_body).send());
235
236    // fetch reports no upload progress, so the whole transfer happens inside the
237    // response wait: give it room for the body at a slow 64 KB/s.
238    #[cfg(target_arch = "wasm32")]
239    let response_wait = RESPONSE_WAIT + std::time::Duration::from_secs(total_size / (64 * 1024));
240    #[cfg(not(target_arch = "wasm32"))]
241    let response_wait = RESPONSE_WAIT;
242    let mut watch = StallWatch::new(total_size, stall_limit, response_wait);
243    let mut last_percentage = 0;
244    let mut poll_interval = crate::rt::time::interval(crate::rt::time::Duration::from_millis(100));
245
246    let response = loop {
247        tokio::select! {
248            response = &mut request_future => {
249                break response.map_err(|e| UploadFailure::Transport(format!("Upload request failed: {}", e)))?;
250            },
251            _ = poll_interval.tick() => {
252                if let Some(flag) = cancel_flag {
253                    if flag.load(Ordering::Relaxed) {
254                        return Err(UploadFailure::Cancelled);
255                    }
256                }
257
258                let current_bytes = *bytes_sent.lock().unwrap();
259                match watch.observe(current_bytes) {
260                    Some(Stall::NoProgress { idle }) => {
261                        return Err(UploadFailure::Transport(format!(
262                            "Upload stalled: {} accepted nothing for {}s ({} of {} bytes sent)",
263                            server_url, idle.as_secs(), current_bytes, total_size,
264                        )));
265                    }
266                    Some(Stall::NoResponse { waited }) => {
267                        return Err(UploadFailure::Transport(format!(
268                            "Upload stalled: {} received all {} bytes but gave no answer in {}s",
269                            server_url, total_size, waited.as_secs(),
270                        )));
271                    }
272                    None => {}
273                }
274
275                if let Some(cb) = progress_callback {
276                    let percentage = if total_size > 0 {
277                        ((current_bytes as f64 / total_size as f64) * 100.0) as u8
278                    } else {
279                        0
280                    };
281                    if percentage != last_percentage {
282                        cb(Some(percentage), Some(current_bytes)).map_err(callback_failure)?;
283                        last_percentage = percentage;
284                    }
285                }
286            }
287        }
288    };
289
290    if let Some(cb) = progress_callback {
291        let final_bytes = *bytes_sent.lock().unwrap();
292        if final_bytes == total_size && last_percentage < 100 {
293            cb(Some(100), Some(total_size)).map_err(callback_failure)?;
294        }
295    }
296    Ok(response)
297}
298
299/// Builds the Blossom authorization header
300async fn build_auth_header<T>(
301    signer: &T,
302    hash: Sha256Hash,
303) -> Result<HeaderValue, String>
304where
305    T: VectorSigner,
306{
307    // Create Blossom authorization
308    let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
309    let auth = BlossomAuthorization::new(
310        "Blossom upload authorization".to_string(),
311        expiration,
312        BlossomAuthorizationVerb::Upload,
313        BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
314    );
315
316    // Sign the authorization event
317    let auth_event: Event = auth
318        .finalize_async(signer)
319        .await
320        .map_err(|e| format!("Failed to sign auth event: {}", e))?;
321
322    // Encode as base64
323    let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
324    let value = format!("Nostr {}", encoded_auth);
325
326    HeaderValue::try_from(value)
327        .map_err(|e| format!("Failed to create header value: {}", e))
328}
329
330/// Upload to a single Blossom server with progress callbacks.
331/// `retry_count` defaults to 0; `retry_spacing` defaults to 1s.
332#[allow(clippy::too_many_arguments)]
333pub async fn upload_blob_with_progress<T>(
334    signer: T,
335    server_url: &Url,
336    file_data: Arc<Vec<u8>>,
337    mime_type: Option<&str>,
338    progress_callback: ProgressCallback,
339    retry_count: Option<u32>,
340    retry_spacing: Option<std::time::Duration>,
341    cancel_flag: Option<Arc<AtomicBool>>,
342) -> Result<String, UploadFailure>
343where
344    T: VectorSigner + Clone,
345{
346    upload_with_retries(signer, server_url, UploadBody::Memory(file_data), mime_type, progress_callback, retry_count, retry_spacing, cancel_flag, true).await
347}
348
349/// [`upload_blob_with_progress`], with the BUD-06 preflight optional: a caller that
350/// already knows the server takes this blob spares the round trip.
351#[allow(clippy::too_many_arguments)]
352async fn upload_with_retries<T>(
353    signer: T,
354    server_url: &Url,
355    body: UploadBody,
356    mime_type: Option<&str>,
357    progress_callback: ProgressCallback,
358    retry_count: Option<u32>,
359    retry_spacing: Option<std::time::Duration>,
360    cancel_flag: Option<Arc<AtomicBool>>,
361    preflight_first: bool,
362) -> Result<String, UploadFailure>
363where
364    T: VectorSigner + Clone,
365{
366    let retry_count = retry_count.unwrap_or(0);
367    let retry_spacing = retry_spacing.unwrap_or(std::time::Duration::from_secs(1));
368
369    let mut last_error = None;
370    let mut next_delay = retry_spacing;
371
372    for attempt in 0..=retry_count {
373        if attempt > 0 {
374            crate::rt::time::sleep(next_delay).await;
375            next_delay = retry_spacing;
376        }
377
378        if let Some(ref flag) = cancel_flag {
379            if flag.load(Ordering::Relaxed) {
380                return Err(UploadFailure::Cancelled);
381            }
382        }
383
384        match upload_attempt(
385            signer.clone(),
386            server_url,
387            body.clone(),
388            mime_type,
389            &progress_callback,
390            cancel_flag.clone(),
391            preflight_first,
392        ).await {
393            Ok(url) => return Ok(url),
394            Err(UploadFailure::Cancelled) => return Err(UploadFailure::Cancelled),
395            Err(e) => {
396                crate::log_warn!(
397                    "[Blossom] Attempt {}/{} to {} failed: {}",
398                    attempt + 1, retry_count + 1, server_url, e,
399                );
400                let again = match &e {
401                    // The server said what it meant; believe it. A coded
402                    // `later` names how long, and a plain server is retried
403                    // unless its status is one that never changes.
404                    UploadFailure::Refused(r) => r.worth_retrying_here(),
405                    // On large uploads, mid-stream drops are almost always a
406                    // size policy; don't burn retries. Below 8MB, treat as a
407                    // genuine transient blip and retry.
408                    UploadFailure::Transport(_) => {
409                        !(e.is_mid_stream_drop() && body.len() > 8 * 1024 * 1024)
410                    }
411                    UploadFailure::Integrity(_) | UploadFailure::Other(_) | UploadFailure::Cancelled => false,
412                };
413                if !again {
414                    if let Some(r) = e.refusal() {
415                        if crate::blossom_error::is_gateway_status(r.status) && r.code().is_none() {
416                            crate::log_warn!(
417                                "[Blossom] {} origin unreachable (status {}) on {} bytes; routing to the next server",
418                                server_url, r.status, body.len(),
419                            );
420                        }
421                    } else if e.is_mid_stream_drop() {
422                        crate::log_warn!(
423                            "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
424                            server_url, body.len(),
425                        );
426                    }
427                    return Err(e);
428                }
429                if let Some(wait) = e.refusal().and_then(|r| r.retry_after()) {
430                    next_delay = next_delay.max(wait);
431                }
432                last_error = Some(e);
433            }
434        }
435    }
436
437    // All attempts failed, return the last error
438    Err(last_error.unwrap_or_else(|| UploadFailure::Other("No upload attempts were made".to_string())))
439}
440
441/// What the server said before the body was sent.
442enum Preflight {
443    /// Send it.
444    Proceed,
445    /// The server already holds this exact blob and named its URL; there is
446    /// nothing to send.
447    AlreadyStored(String),
448}
449
450/// BUD-06 preflight: `HEAD /upload` with the blob's hash, size and type, so a
451/// server that will refuse can say so before the bytes go out.
452///
453/// Best-effort: a server without BUD-06 answers 404/405 and is sent the body
454/// regardless. A server with Magnitude's vocabulary puts `X-Error-Code` on the
455/// refusal, and every such code is final for this server — a quota, a closed
456/// client gate, a blocked hash are as final as a size limit, and sending the
457/// body would only earn the same answer after the transfer. A plain server is
458/// believed only on the two statuses BUD-06 defines (413, 415) and on a body
459/// that reads as a type rejection.
460///
461/// A 2xx with `X-Already-Stored: true` and `X-Blob-URL` means the server has
462/// these bytes already and the caller can stop here with the link.
463async fn preflight(
464    client: &reqwest::Client,
465    upload_url: &Url,
466    server_url: &Url,
467    auth_header: &HeaderValue,
468    hash: Sha256Hash,
469    total_size: u64,
470    mime_type: Option<&str>,
471) -> Result<Preflight, UploadFailure> {
472    let mut head_headers = HeaderMap::new();
473    head_headers.insert(AUTHORIZATION, auth_header.clone());
474    head_headers.insert(
475        "X-Content-Length",
476        HeaderValue::from_str(&total_size.to_string())
477            .map_err(|e| UploadFailure::Other(format!("Invalid X-Content-Length: {}", e)))?,
478    );
479    // BUD-06 requires lowercase hex. SIMD encode of the 32-byte digest (sha256::Hash displays
480    // in forward byte order, matching to_byte_array — see the parity test).
481    head_headers.insert(
482        "X-SHA-256",
483        HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
484            .map_err(|e| UploadFailure::Other(format!("Invalid X-SHA-256: {}", e)))?,
485    );
486    if let Some(ct) = mime_type {
487        head_headers.insert(
488            "X-Content-Type",
489            HeaderValue::from_str(ct).map_err(|e| UploadFailure::Other(format!("Invalid X-Content-Type: {}", e)))?,
490        );
491    }
492    let asked_at = web_time::Instant::now();
493    let resp = match crate::rt::time::timeout(
494        std::time::Duration::from_secs(5),
495        client.head(upload_url.clone()).headers(head_headers).send(),
496    ).await {
497        Ok(Ok(resp)) => {
498            crate::blossom_stats::record_latency(server_url.as_str(), asked_at.elapsed().as_secs_f64() * 1000.0);
499            resp
500        }
501        Ok(Err(e)) => {
502            crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
503            return Ok(Preflight::Proceed);
504        }
505        Err(_) => {
506            crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
507            return Ok(Preflight::Proceed);
508        }
509    };
510
511    let status = resp.status();
512    if status.is_success() {
513        let already = resp.headers().get("x-already-stored")
514            .and_then(|v| v.to_str().ok())
515            .is_some_and(|v| v.eq_ignore_ascii_case("true"));
516        let url = resp.headers().get("x-blob-url")
517            .and_then(|v| v.to_str().ok())
518            .map(|s| s.to_string());
519        if let (true, Some(url)) = (already, url) {
520            // The link must name our hash: a server pointing at some other blob
521            // would have us embed a stranger's bytes in a message.
522            match parse_blob_url(&url) {
523                Ok((_, stored)) if stored == hash => {
524                    crate::log_info!(
525                        "[Blossom Preflight] {} already holds {} ({} bytes); nothing to send",
526                        server_url, hash, total_size,
527                    );
528                    return Ok(Preflight::AlreadyStored(url));
529                }
530                _ => crate::log_warn!(
531                    "[Blossom Preflight] {} claims to hold the blob at {} which is not {}; uploading anyway",
532                    server_url, url, hash,
533                ),
534            }
535        }
536        crate::log_debug!(
537            "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
538            server_url, status, total_size,
539        );
540        return Ok(Preflight::Proceed);
541    }
542
543    // A HEAD has no body: the headers are the whole answer. BUD-02 says the
544    // prose in X-Reason is display-only; a body, if any, feeds the keyword
545    // classifier for servers that 400 instead of 415.
546    let headers = resp.headers().clone();
547    let body = resp.text().await.unwrap_or_default();
548    let refusal = Refusal::from_response(status.as_u16(), &headers, &body);
549    let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
550    let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
551    let final_here = refusal.code().is_some()
552        || is_413
553        || is_415
554        || (status.is_client_error() && refusal.is_mime());
555    if final_here {
556        crate::log_warn!(
557            "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
558            server_url, status, total_size,
559            mime_type.unwrap_or("(no mime)"), refusal.message,
560        );
561        return Err(UploadFailure::Refused(refusal));
562    }
563    crate::log_debug!(
564        "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
565        server_url, status, total_size,
566    );
567    Ok(Preflight::Proceed)
568}
569
570/// Read a rejected PUT into a refusal. The body feeds the classifier; the
571/// headers carry the code when the server has one.
572async fn refusal_from(response: reqwest::Response) -> Refusal {
573    let status = response.status().as_u16();
574    let headers = response.headers().clone();
575    let body = response.text().await.unwrap_or_default();
576    Refusal::from_response(status, &headers, &body)
577}
578
579/// Internal function that performs a single upload attempt with progress tracking
580async fn upload_attempt<T>(
581    signer: T,
582    server_url: &Url,
583    body: UploadBody,
584    mime_type: Option<&str>,
585    progress_callback: &ProgressCallback,
586    cancel_flag: Option<Arc<AtomicBool>>,
587    preflight_first: bool,
588) -> Result<String, UploadFailure>
589where
590    T: VectorSigner,
591{
592    let upload_url = server_url.join("upload")
593        .map_err(|e| UploadFailure::Other(format!("Invalid server URL: {}", e)))?;
594
595    let total_size = body.len();
596    let hash = body.sha256();
597
598    progress_callback(Some(0), Some(0)).map_err(callback_failure)?;
599
600    // One auth event covers both HEAD preflight and PUT.
601    let auth_header = build_auth_header(&signer, hash).await.map_err(UploadFailure::Other)?;
602
603    // No deadlines: the upload is bounded by progress in `send_upload`.
604    // Redirects disabled: a 3xx mid-PUT would re-issue as GET and drop the body.
605    let client = crate::net::build_http_client_with_options(None, None, false)
606        .map_err(UploadFailure::Other)?;
607
608    if preflight_first {
609        match preflight(&client, &upload_url, server_url, &auth_header, hash, total_size, mime_type).await? {
610            Preflight::Proceed => {}
611            Preflight::AlreadyStored(url) => {
612                progress_callback(Some(100), Some(total_size)).map_err(callback_failure)?;
613                return Ok(url);
614            }
615        }
616    }
617
618    let mut headers = HeaderMap::new();
619    headers.insert(AUTHORIZATION, auth_header);
620    if let Some(ct) = mime_type {
621        headers.insert(
622            CONTENT_TYPE,
623            HeaderValue::from_str(ct).map_err(|e| UploadFailure::Other(format!("Invalid content type: {}", e)))?
624        );
625    }
626    // `Body::wrap_stream` is unknown-length so reqwest would default to
627    // chunked encoding and omit Content-Length — some servers (e.g.
628    // blossom.data.haus) then 411.
629    headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
630
631    let started = web_time::Instant::now();
632    let response = send_upload(
633        client.put(upload_url.clone()).headers(headers),
634        server_url,
635        &body,
636        crate::net::TRANSFER_STALL,
637        cancel_flag.as_ref(),
638        Some(progress_callback),
639    ).await?;
640
641    // BUD-02: accept any 2xx (200 OK or 201 Created).
642    let status = response.status();
643    if status.is_success() {
644        crate::blossom_stats::record_upload(server_url.as_str(), total_size, started.elapsed().as_secs_f64() * 1000.0);
645        let descriptor: BlobDescriptor = response.json().await
646            .map_err(|e| UploadFailure::Other(format!("Failed to parse response: {}", e)))?;
647        // Integrity gate: a compliant server stores our bytes verbatim, so the
648        // returned descriptor hash MUST equal what we uploaded. A mismatch means
649        // the server transformed/re-encoded the blob, which is fatal for an
650        // encrypted upload (corrupts the ciphertext). The failover loop routes
651        // around the server like a hard rejection.
652        if descriptor.sha256 != hash {
653            return Err(UploadFailure::Integrity(format!(
654                "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
655                server_url, descriptor.sha256, hash,
656            )));
657        }
658        Ok(descriptor.url.to_string())
659    } else {
660        let refusal = refusal_from(response).await;
661        crate::log_net_fail!(
662            "[Blossom] upload rejected: HTTP {}{} — {}",
663            status,
664            refusal.code().map(|c| format!(" [{}]", c)).unwrap_or_default(),
665            refusal.message,
666        );
667        Err(UploadFailure::Refused(refusal))
668    }
669}
670
671/// Simple upload without progress reporting.
672///
673/// `stall_timeout` is how long the server may accept nothing before it is
674/// treated as dead and failover moves on; `None` is [`crate::net::TRANSFER_STALL`].
675/// Small uploads (emoji, avatars) pass a short one so a broken host costs
676/// seconds. There is no total time limit at any size.
677pub async fn upload_blob<T>(
678    signer: T,
679    server_url: &Url,
680    file_data: Arc<Vec<u8>>,
681    mime_type: Option<&str>,
682    stall_timeout: Option<std::time::Duration>,
683) -> Result<String, UploadFailure>
684where
685    T: VectorSigner,
686{
687    let upload_url = server_url.join("upload")
688        .map_err(|e| UploadFailure::Other(format!("Invalid server URL: {}", e)))?;
689
690    let hash = Sha256Hash::hash(&file_data);
691    let total_size = file_data.len() as u64;
692
693    let auth_header = build_auth_header(&signer, hash).await.map_err(UploadFailure::Other)?;
694
695    // Redirects disabled so a 3xx mid-PUT doesn't re-issue as GET.
696    let client = crate::net::build_http_client_with_options(None, None, false)
697        .map_err(UploadFailure::Other)?;
698
699    if let Preflight::AlreadyStored(url) =
700        preflight(&client, &upload_url, server_url, &auth_header, hash, total_size, mime_type).await?
701    {
702        return Ok(url);
703    }
704
705    let mut headers = HeaderMap::new();
706    headers.insert(AUTHORIZATION, auth_header);
707    if let Some(ct) = mime_type {
708        headers.insert(
709            CONTENT_TYPE,
710            HeaderValue::from_str(ct).map_err(|e| UploadFailure::Other(format!("Invalid content type: {}", e)))?
711        );
712    }
713    headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
714
715    let started = web_time::Instant::now();
716    let response = send_upload(
717        client.put(upload_url).headers(headers),
718        server_url,
719        &UploadBody::Memory(file_data),
720        stall_timeout.unwrap_or(crate::net::TRANSFER_STALL),
721        None,
722        None,
723    ).await?;
724
725    // BUD-02: accept any 2xx (200 OK or 201 Created).
726    let status = response.status();
727    if status.is_success() {
728        crate::blossom_stats::record_upload(server_url.as_str(), total_size, started.elapsed().as_secs_f64() * 1000.0);
729        let descriptor: BlobDescriptor = response.json().await
730            .map_err(|e| UploadFailure::Other(format!("Failed to parse response: {}", e)))?;
731        // Integrity gate (see upload_attempt): reject a server that returns a
732        // different hash than we uploaded — it re-encoded the blob.
733        if descriptor.sha256 != hash {
734            return Err(UploadFailure::Integrity(format!(
735                "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
736                server_url, descriptor.sha256, hash,
737            )));
738        }
739        Ok(descriptor.url.to_string())
740    } else {
741        Err(UploadFailure::Refused(refusal_from(response).await))
742    }
743}
744
745/// STRICT liveness for smart-forward reuse: only a positive 2xx HEAD counts.
746/// The post-upload verifier (`uploaded_blob_serves`) fails OPEN, since an unreachable
747/// server shouldn't sink an upload that ACKed; a REUSE decision is the opposite: on
748/// anything but proof the blob serves, fall through to a fresh upload, which always works.
749pub async fn blob_is_served(url: &str, timeout: std::time::Duration) -> bool {
750    matches!(crate::net::remote_status(url, timeout).await, Some(200..=299))
751}
752
753/// Post-upload liveness gate: confirm the server actually SERVES the blob it just ACKed.
754/// Some servers 2xx an upload they dedupe against a stale index or quietly drop, so only a
755/// cache-cold fetch tells the truth. A definitive 404/410 (after a short grace retry) fails
756/// the server; anything else passes, so a flaky HEAD can't sink a good upload.
757async fn uploaded_blob_serves(url: &str) -> bool {
758    for attempt in 0..2 {
759        if attempt > 0 {
760            crate::rt::time::sleep(std::time::Duration::from_secs(1)).await;
761        }
762        match uploaded_blob_status(url).await {
763            Some(404) | Some(410) => continue,
764            Some(_) => return true,
765            None => {
766                crate::log_net_info!("[Blossom] {} unreachable for post-upload verify — assuming stored", url);
767                return true;
768            }
769        }
770    }
771    crate::log_net_fail!("[Blossom] {} ACKed the upload but serves 404/410 — treating as dropped", url);
772    false
773}
774
775/// The status a just-uploaded blob answers with. Asked directly, it rides the
776/// upload's pooled connection instead of opening a fresh one to the same host.
777async fn uploaded_blob_status(url: &str) -> Option<u16> {
778    let timeout = std::time::Duration::from_secs(10);
779    if crate::net::egress(url).await.proxied() {
780        return crate::net::remote_status(url, timeout).await;
781    }
782    let client = crate::net::build_http_client_with_options(None, None, false).ok()?;
783    let status = crate::rt::time::timeout(timeout, client.head(url).send()).await.ok()?.ok()?.status();
784    if status == StatusCode::METHOD_NOT_ALLOWED || status == StatusCode::NOT_IMPLEMENTED {
785        return crate::net::remote_status(url, timeout).await;
786    }
787    Some(status.as_u16())
788}
789
790/// What a failure says about the server's state for the status pill: gone
791/// (nothing arrived) or full (a quota or the disk). A refusal about the file
792/// itself says nothing about the server.
793fn note_failure(server_url: &str, e: &UploadFailure) {
794    match e {
795        UploadFailure::Transport(_) => {
796            crate::blossom_stats::record_failure(server_url, crate::blossom_stats::FAIL_OFFLINE)
797        }
798        UploadFailure::Refused(r) => {
799            if matches!(r.code(), Some("quota_storage_exceeded" | "quota_daily_exceeded" | "server_full")) {
800                crate::blossom_stats::record_failure(server_url, crate::blossom_stats::FAIL_MAXED);
801            } else {
802                crate::blossom_stats::record_ok(server_url);
803            }
804        }
805        _ => {}
806    }
807}
808
809/// Upload to multiple Blossom servers with failover, in input order.
810///
811/// **Does NOT participate in the capability cache.** Used by the
812/// marketplace (plaintext mini-app uploads); for high-volume callers
813/// prefer `upload_blob_with_progress_and_failover` so they benefit
814/// from cache-aware routing.
815pub async fn upload_blob_with_failover<T>(
816    signer: T,
817    server_urls: Vec<String>,
818    file_data: Arc<Vec<u8>>,
819    mime_type: Option<&str>,
820    stall_timeout: Option<std::time::Duration>,
821) -> Result<String, String>
822where
823    T: VectorSigner + Clone,
824{
825    let mut failures: Vec<(String, UploadFailure)> = Vec::new();
826
827    for (index, server_url_str) in server_urls.iter().enumerate() {
828        let host = host_of(server_url_str);
829        let server_url = match Url::parse(server_url_str) {
830            Ok(url) => url,
831            Err(e) => {
832                crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
833                failures.push((host, UploadFailure::Other(format!("Invalid server URL: {}", e))));
834                continue;
835            }
836        };
837
838        crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
839            index + 1, server_urls.len(), server_url_str);
840
841        match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type, stall_timeout).await {
842            Ok(url) => {
843                if !uploaded_blob_serves(&url).await {
844                    crate::log_warn!(
845                        "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
846                        server_url_str, url,
847                    );
848                    failures.push((host, UploadFailure::Other(format!(
849                        "{} accepted the upload but the blob is not retrievable", server_url_str,
850                    ))));
851                    continue;
852                }
853                crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
854                crate::blossom_stats::record_ok(server_url_str);
855                return Ok(url);
856            }
857            Err(e) => {
858                crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
859                note_failure(server_url_str, &e);
860                failures.push((host, e));
861            }
862        }
863    }
864
865    let summary = summarise_failures(&failures);
866    crate::log_net_fail!("[Blossom] ALL servers failed: {}", summary);
867    Err(summary)
868}
869
870/// An upload that a server accepted.
871///
872/// `url` is the descriptor the server returned; `server` is the address Vector
873/// actually talked to. They are frequently NOT the same host: an ingress relay
874/// takes the bytes on a regional name and the origin hands back its own
875/// canonical `public_url`. Both name the same server, and mirroring needs to
876/// know that — a BUD-04 mirror aimed at either one asks a server to fetch a
877/// blob it is already holding.
878#[derive(Debug, Clone, PartialEq, Eq)]
879pub struct AcceptedUpload {
880    pub url: String,
881    pub server: String,
882}
883
884/// Open a pooled connection to the server an upload of this kind would try first,
885/// so a send that follows starts on a live TLS session rather than a handshake.
886/// Best-effort and bodiless: the answer is irrelevant, the connection is the point.
887pub async fn warm_upload_connection(server_urls: Vec<String>, mime_type: &str, is_encrypted: bool, size_bytes: u64) {
888    let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_type, is_encrypted, size_bytes);
889    let Some(url) = ranked.first().and_then(|u| Url::parse(u).ok()) else { return };
890    // The same options as `upload_attempt`, so the upload draws from this pool.
891    let Ok(client) = crate::net::build_http_client_with_options(None, None, false) else { return };
892    let _ = crate::rt::time::timeout(std::time::Duration::from_secs(10), client.head(url).send()).await;
893}
894
895/// Upload with progress + failover, cache-aware routing, and capability learning.
896#[allow(clippy::too_many_arguments)]
897pub async fn upload_blob_with_progress_and_failover<T>(
898    signer: T,
899    server_urls: Vec<String>,
900    file_data: Arc<Vec<u8>>,
901    mime_type: Option<&str>,
902    is_encrypted: bool,
903    progress_callback: ProgressCallback,
904    retry_count: Option<u32>,
905    retry_spacing: Option<std::time::Duration>,
906    cancel_flag: Option<Arc<AtomicBool>>,
907) -> Result<AcceptedUpload, String>
908where
909    T: VectorSigner + Clone,
910{
911    upload_body_with_progress_and_failover(
912        signer, server_urls, UploadBody::Memory(file_data), mime_type, is_encrypted,
913        progress_callback, retry_count, retry_spacing, cancel_flag,
914    ).await
915}
916
917/// [`upload_blob_with_progress_and_failover`] for any [`UploadBody`], a file on
918/// disk included.
919#[allow(clippy::too_many_arguments)]
920pub async fn upload_body_with_progress_and_failover<T>(
921    signer: T,
922    server_urls: Vec<String>,
923    body: UploadBody,
924    mime_type: Option<&str>,
925    is_encrypted: bool,
926    progress_callback: ProgressCallback,
927    retry_count: Option<u32>,
928    retry_spacing: Option<std::time::Duration>,
929    cancel_flag: Option<Arc<AtomicBool>>,
930) -> Result<AcceptedUpload, String>
931where
932    T: VectorSigner + Clone,
933{
934    let mut failures: Vec<(String, UploadFailure)> = Vec::new();
935
936    // Known-good first, unknown second, MIME-rejected last. Stable within
937    // tier so the user's BUD-03 trust order wins ties.
938    let size_bytes = body.len();
939    let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
940    let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
941
942    for (index, server_url_str) in ranked.iter().enumerate() {
943        if let Some(ref flag) = cancel_flag {
944            if flag.load(Ordering::Relaxed) {
945                return Err("Upload cancelled".to_string());
946            }
947        }
948
949        let host = host_of(server_url_str);
950        let server_url = match Url::parse(server_url_str) {
951            Ok(url) => url,
952            Err(e) => {
953                crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
954                failures.push((host, UploadFailure::Other(format!("Invalid server URL: {}", e))));
955                continue;
956            }
957        };
958
959        crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
960            index + 1, ranked.len(), server_url_str);
961
962        // A fresh random key makes every ciphertext new, so a preflight can only
963        // refuse; it is skipped where this server has already taken its like.
964        let preflight_first = !crate::blossom_capabilities::preflight_redundant(
965            server_url_str, mime_for_routing, is_encrypted, size_bytes,
966        );
967        match upload_with_retries(
968            signer.clone(),
969            &server_url,
970            body.clone(),
971            mime_type,
972            progress_callback.clone(),
973            retry_count,
974            retry_spacing,
975            cancel_flag.clone(),
976            preflight_first,
977        ).await {
978            Ok(url) => {
979                if !uploaded_blob_serves(&url).await {
980                    crate::log_warn!(
981                        "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
982                        server_url_str, url,
983                    );
984                    failures.push((host, UploadFailure::Other(format!(
985                        "{} accepted the upload but the blob is not retrievable", server_url_str,
986                    ))));
987                    let _ = progress_callback(Some(0), Some(0));
988                    continue;
989                }
990                crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
991                if let Err(err) = crate::blossom_capabilities::record_accepted(
992                    server_url_str, mime_for_routing, is_encrypted, size_bytes,
993                ) {
994                    crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
995                }
996                crate::blossom_info::note_upload(server_url_str, size_bytes);
997                crate::blossom_stats::record_ok(server_url_str);
998                return Ok(AcceptedUpload {
999                    url,
1000                    server: server_url_str.clone(),
1001                });
1002            }
1003            Err(UploadFailure::Cancelled) => {
1004                return Err("Upload cancelled".to_string());
1005            }
1006            Err(e) => {
1007                crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
1008                note_failure(server_url_str, &e);
1009                // Worth remembering about this server: a type it does not
1010                // take, a size it does not take, bytes it does not keep intact.
1011                // A quota, a rate limit, a closed gate or a bad clock say
1012                // nothing about the NEXT upload and are not cached.
1013                match &e {
1014                    UploadFailure::Integrity(_) => {
1015                        if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1016                            server_url_str, mime_for_routing, is_encrypted,
1017                        ) {
1018                            crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
1019                        }
1020                    }
1021                    UploadFailure::Refused(r) if r.is_mime() => {
1022                        if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1023                            server_url_str, mime_for_routing, is_encrypted,
1024                        ) {
1025                            crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
1026                        }
1027                    }
1028                    UploadFailure::Refused(r) if r.is_size_limit() => {
1029                        // A server that named its limit has told us the exact
1030                        // edge; one that only said 413 has told us this size.
1031                        let rejects_from = r.limit().map(|l| l.saturating_add(1)).unwrap_or(size_bytes).min(size_bytes);
1032                        if let Err(err) = crate::blossom_capabilities::record_rejected_size(
1033                            server_url_str, mime_for_routing, is_encrypted, rejects_from,
1034                        ) {
1035                            crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
1036                        }
1037                    }
1038                    // Mid-stream drops aren't cached (too ambiguous); only
1039                    // an explicit size refusal sets min_rejected_size.
1040                    _ => {}
1041                }
1042                failures.push((host, e));
1043                let _ = progress_callback(Some(0), Some(0));
1044            }
1045        }
1046    }
1047
1048    let summary = summarise_failures(&failures);
1049    crate::log_net_fail!("[Blossom] ALL servers failed: {}", summary);
1050    Err(summary)
1051}
1052
1053// ============================================================================
1054// Blossom DELETE — paired with NIP-17 message deletion
1055// ============================================================================
1056
1057/// Build a BUD-01 DELETE authorization header (kind-24242, verb=delete).
1058async fn build_delete_auth_header<T>(
1059    signer: &T,
1060    hash: Sha256Hash,
1061) -> Result<HeaderValue, String>
1062where
1063    T: VectorSigner,
1064{
1065    let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
1066    let auth = BlossomAuthorization::new(
1067        "Blossom delete authorization".to_string(),
1068        expiration,
1069        BlossomAuthorizationVerb::Delete,
1070        BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
1071    );
1072
1073    let auth_event: Event = auth
1074        .finalize_async(signer)
1075        .await
1076        .map_err(|e| format!("Failed to sign auth event: {}", e))?;
1077
1078    let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
1079    let value = format!("Nostr {}", encoded_auth);
1080
1081    HeaderValue::try_from(value)
1082        .map_err(|e| format!("Failed to create header value: {}", e))
1083}
1084
1085/// Delete a blob from a Blossom server. 2xx and 404 both count as
1086/// success (idempotent: "blob is gone" is the goal). 401/403/5xx and
1087/// network errors propagate.
1088pub async fn delete_blob<T>(
1089    signer: T,
1090    server_url: &Url,
1091    hash: Sha256Hash,
1092) -> Result<(), String>
1093where
1094    T: VectorSigner + Clone,
1095{
1096    let auth_header = build_delete_auth_header(&signer, hash).await?;
1097
1098    let mut url = server_url.clone();
1099    // BUD-01 DELETE endpoint: `<origin>/<hash>`.
1100    url.set_path(&format!("/{}", hash));
1101
1102    let mut headers = HeaderMap::new();
1103    headers.insert(AUTHORIZATION, auth_header);
1104
1105    let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
1106
1107    let response = client
1108        .delete(url)
1109        .headers(headers)
1110        .send()
1111        .await
1112        .map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
1113
1114    let status = response.status();
1115    if status.is_success() || status == StatusCode::NOT_FOUND {
1116        Ok(())
1117    } else {
1118        let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
1119        // "with status N" phrasing so `parse_status_from_error` can read the
1120        // code (the probe uses it to detect deletion-refusal). 404 already
1121        // counts as success above (blob gone = effectively deleted).
1122        Err(format!("Blossom DELETE failed with status {}: {}", status, body))
1123    }
1124}
1125
1126/// Parse a Blossom blob URL into its server origin and SHA-256 hash
1127/// (last non-empty path segment, optional `.ext` stripped).
1128pub fn parse_blob_url(url_str: &str) -> Result<(Url, Sha256Hash), String> {
1129    let parsed = Url::parse(url_str)
1130        .map_err(|e| format!("Invalid Blossom URL: {}", e))?;
1131    let last_segment = parsed
1132        .path_segments()
1133        .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
1134        .ok_or_else(|| "Blossom URL has no path segment".to_string())?;
1135    let hash_str = last_segment.split('.').next().unwrap_or("");
1136    let hash = Sha256Hash::from_str(hash_str)
1137        .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
1138    let mut origin = parsed.clone();
1139    origin.set_path("/");
1140    origin.set_query(None);
1141    origin.set_fragment(None);
1142    Ok((origin, hash))
1143}
1144
1145/// The SHA-256 a Blossom URL names, as hex: what its bytes must hash to. `None`
1146/// for a URL that isn't content-addressed.
1147pub fn blob_hash(url_str: &str) -> Option<String> {
1148    parse_blob_url(url_str).ok().map(|(_, hash)| hash.to_string())
1149}
1150
1151/// Verify a downloaded body against its URL's content address.
1152/// `Some(true)` = bytes match the blob hash, `Some(false)` = the source served
1153/// the wrong bytes, `None` = the URL carries no content address to check.
1154pub fn verify_blob_content(url_str: &str, bytes: &[u8]) -> Option<bool> {
1155    let (_, expected) = parse_blob_url(url_str).ok()?;
1156    Some(Sha256Hash::hash(bytes) == expected)
1157}
1158
1159/// Parse a Blossom blob URL into (origin, hash) and DELETE that blob.
1160/// Awaitable single-URL variant of `delete_blobs_best_effort` — caller
1161/// drives sequencing + per-URL UI feedback.
1162pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
1163where
1164    T: VectorSigner + Clone,
1165{
1166    let (origin, hash) = parse_blob_url(url_str)?;
1167
1168    crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
1169    // Hard ceiling — a black-holed server must not hang the caller's
1170    // UI (e.g. the pack creator's "Deleting…" overlay) indefinitely.
1171    // 15s is generous for a healthy server and short enough that a
1172    // misbehaving one fails over to the next blob in a batch quickly.
1173    let timeout = std::time::Duration::from_secs(15);
1174    match crate::rt::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
1175        Ok(Ok(())) => {
1176            crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
1177            Ok(())
1178        }
1179        Ok(Err(e)) => {
1180            crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
1181            Err(e)
1182        }
1183        Err(_) => {
1184            let msg = format!("DELETE timed out after {}s", timeout.as_secs());
1185            crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
1186            Err(msg)
1187        }
1188    }
1189}
1190
1191/// Derive BUD-03 hash-swap candidates: the same content-address on each of
1192/// `servers`. Blossom URLs are `<origin>/<sha256>[.ext]`, so any server in
1193/// the author's list may serve a blob whose embedded URLs have all died.
1194/// Servers matching `primary_url`'s origin (already tried) are skipped.
1195pub fn hash_swap_candidates(primary_url: &str, servers: &[String]) -> Vec<String> {
1196    let Ok((primary_origin, hash)) = parse_blob_url(primary_url) else {
1197        return Vec::new();
1198    };
1199    // Extension from the parsed path segment, never the raw string — a raw
1200    // rsplit would drag the query/fragment along into every derived URL.
1201    let ext = Url::parse(primary_url)
1202        .ok()
1203        .and_then(|u| {
1204            u.path_segments()
1205                .and_then(|segs| segs.rev().find(|s| !s.is_empty()).map(|s| s.to_string()))
1206        })
1207        .and_then(|leaf| leaf.split_once('.').map(|(_, e)| e.to_string()))
1208        .filter(|e| !e.is_empty() && e.len() <= 8 && e.bytes().all(|b| b.is_ascii_alphanumeric()));
1209    let leaf = match ext {
1210        Some(e) => format!("{}.{}", hash, e),
1211        None => hash.to_string(),
1212    };
1213    servers
1214        .iter()
1215        .filter_map(|s| {
1216            let base = Url::parse(&format!("{}/", s.trim_end_matches('/'))).ok()?;
1217            if base.origin() == primary_origin.origin() {
1218                return None;
1219            }
1220            base.join(&leaf).ok().map(|u| u.to_string())
1221        })
1222        .collect()
1223}
1224
1225/// BUD-04: ask `target_server` to mirror the blob at `source_url` by pulling
1226/// it server-to-server (the client sends one small JSON request, never the
1227/// bytes). Authorized by the same hash-scoped upload event as a direct
1228/// upload. The mirror's ACK gets the same serve-check as an upload ACK.
1229/// Returns the mirror's URL for the blob.
1230pub async fn mirror_blob<T>(signer: T, target_server: &Url, source_url: &str) -> Result<String, String>
1231where
1232    T: VectorSigner + Clone,
1233{
1234    let (source_origin, hash) = parse_blob_url(source_url)?;
1235    if target_server.origin() == source_origin.origin() {
1236        return Err("Mirror target is the source server".to_string());
1237    }
1238    let auth_header = build_auth_header(&signer, hash).await?;
1239    let mirror_url = target_server
1240        .join("mirror")
1241        .map_err(|e| format!("Invalid mirror URL: {}", e))?;
1242
1243    let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
1244    let response = client
1245        .put(mirror_url)
1246        .header(AUTHORIZATION, auth_header)
1247        .header(CONTENT_TYPE, "application/json")
1248        .body(format!("{{\"url\":{}}}", serde_json::to_string(source_url).map_err(|e| e.to_string())?))
1249        .send()
1250        .await
1251        .map_err(|e| format!("Mirror request failed: {}", e))?;
1252
1253    let status = response.status();
1254    if !status.is_success() {
1255        let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
1256        return Err(format!("Mirror failed with status {}: {}", status, body));
1257    }
1258    let descriptor: BlobDescriptor = response
1259        .json()
1260        .await
1261        .map_err(|e| format!("Failed to parse mirror response: {}", e))?;
1262    if descriptor.sha256 != hash {
1263        return Err(format!(
1264            "[INTEGRITY] {} mirrored a different hash (returned {}, expected {})",
1265            target_server, descriptor.sha256, hash,
1266        ));
1267    }
1268    let url = descriptor.url.to_string();
1269    // BUD-04: the mirror serves the blob ITSELF. A descriptor pointing at a
1270    // foreign origin, a downgraded scheme, or carrying a query string is a
1271    // protocol violation (or a tracking beacon) — never embed it in messages.
1272    let same_origin = Url::parse(&url)
1273        .map(|u| u.origin() == target_server.origin() && u.query().is_none())
1274        .unwrap_or(false);
1275    if !same_origin {
1276        return Err(format!("{} returned a foreign descriptor URL: {}", target_server, url));
1277    }
1278    if !uploaded_blob_serves(&url).await {
1279        return Err(format!("{} ACKed the mirror but does not serve it", target_server));
1280    }
1281    Ok(url)
1282}
1283
1284/// Which of the user's servers are worth mirroring to.
1285///
1286/// A server that already holds the blob gains nothing from being told to fetch
1287/// it, and mirroring exists for redundancy across DISTINCT servers — a server
1288/// that just accepted the upload is by definition not adding any.
1289///
1290/// The source URL alone does not identify those servers. An upload sent to an
1291/// ingress relay comes back carrying the origin's canonical `public_url`, so
1292/// `asia.magnitude.jskitty.com` and `magnitude.jskitty.com` are one server
1293/// wearing two names, and no amount of string comparison relates them. The
1294/// caller knows which address it actually uploaded to, so it passes that in
1295/// via `already_held_by` and both names are excluded.
1296fn mirror_targets(
1297    source_url: &str,
1298    server_urls: &[String],
1299    max_mirrors: usize,
1300    already_held_by: &[String],
1301) -> Vec<Url> {
1302    let source_origin = match parse_blob_url(source_url) {
1303        Ok((origin, _)) => origin,
1304        Err(e) => {
1305            crate::log_debug!("[Blossom Mirror] unparseable source {}: {}", source_url, e);
1306            return Vec::new();
1307        }
1308    };
1309    let excluded: Vec<url::Origin> = std::iter::once(source_origin.origin())
1310        .chain(
1311            already_held_by
1312                .iter()
1313                .filter_map(|s| Url::parse(s).ok())
1314                .map(|u| u.origin()),
1315        )
1316        .collect();
1317    server_urls
1318        .iter()
1319        .filter_map(|s| Url::parse(s).ok())
1320        .filter(|u| !excluded.contains(&u.origin()))
1321        .take(max_mirrors)
1322        .collect()
1323}
1324
1325/// Best-effort BUD-04 fan-out: mirror `source_url` onto up to `max_mirrors`
1326/// of the user's other servers, concurrently, under one wall-clock `budget`.
1327/// Returns only the mirror URLs that verifiably serve — the caller embeds
1328/// these as NIP-17 / imeta `fallback` sources. Never fails the send: an
1329/// empty Vec just means the message ships with no fallbacks.
1330pub async fn mirror_blob_to_servers<T>(
1331    signer: T,
1332    source_url: &str,
1333    server_urls: Vec<String>,
1334    max_mirrors: usize,
1335    budget: std::time::Duration,
1336    already_held_by: &[String],
1337) -> Vec<String>
1338where
1339    T: VectorSigner + Clone,
1340{
1341    let targets = mirror_targets(source_url, &server_urls, max_mirrors, already_held_by);
1342    if targets.is_empty() {
1343        return Vec::new();
1344    }
1345
1346    let futures = targets.into_iter().map(|target| {
1347        let signer = signer.clone();
1348        let source = source_url.to_string();
1349        async move {
1350            // Per-target budget: a hung server cancels only itself. A mirror
1351            // that completed must keep its result — an unrecorded live copy
1352            // is invisible to every future deletion sweep.
1353            match crate::rt::time::timeout(budget, mirror_blob(signer, &target, &source)).await {
1354                Ok(Ok(url)) => {
1355                    crate::log_net_info!("[Blossom Mirror] {} now serves {}", target, url);
1356                    Some(url)
1357                }
1358                Ok(Err(e)) => {
1359                    crate::log_net_fail!("[Blossom Mirror] {} refused: {}", target, e);
1360                    None
1361                }
1362                Err(_) => {
1363                    crate::log_net_fail!("[Blossom Mirror] {} exceeded {:?} budget", target, budget);
1364                    None
1365                }
1366            }
1367        }
1368    });
1369    futures_util::future::join_all(futures)
1370        .await
1371        .into_iter()
1372        .flatten()
1373        .collect()
1374}
1375
1376/// Fire-and-forget DELETE for each parseable blob URL. Pairs with
1377/// `delete_own_dm` so removing a NIP-17 file message also removes
1378/// the ciphertext from the server it was uploaded to.
1379pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
1380where
1381    T: VectorSigner + Clone + Send + Sync + 'static,
1382{
1383    for url_str in urls {
1384        let url = match Url::parse(&url_str) {
1385            Ok(u) => u,
1386            Err(_) => continue,
1387        };
1388
1389        // Last non-empty path segment (trailing-slash URLs leave an empty tail).
1390        let last_segment = match url.path_segments()
1391            .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
1392        {
1393            Some(s) => s,
1394            None => continue,
1395        };
1396        // Strip an optional `.ext` suffix some servers append.
1397        let hash_str = last_segment.split('.').next().unwrap_or("");
1398        let hash = match Sha256Hash::from_str(hash_str) {
1399            Ok(h) => h,
1400            Err(_) => continue,
1401        };
1402
1403        let mut origin = url.clone();
1404        origin.set_path("/");
1405        origin.set_query(None);
1406        origin.set_fragment(None);
1407
1408        let signer = signer.clone();
1409        // spawn-detached: deleting one probe blob from a server — signer in hand, no account storage.
1410        crate::rt::spawn(async move {
1411            if let Err(e) = delete_blob(signer, &origin, hash).await {
1412                crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
1413            }
1414        });
1415    }
1416}
1417
1418/// Probe `(server, application/octet-stream, encrypted=true)` with a
1419/// 32-byte random blob to learn whether the server accepts the binary
1420/// uploads Vector produces for chat attachments. Single-shot per
1421/// (server,mime,encrypted). Successful probes are cleaned up via DELETE.
1422pub async fn probe_servers_for_octet_stream<T>(
1423    signer: T,
1424    server_urls: Vec<String>,
1425) -> Result<usize, String>
1426where
1427    T: VectorSigner + Clone,
1428{
1429    use rand::RngCore;
1430    if server_urls.is_empty() { return Ok(0); }
1431
1432    const PROBE_MIME: &str = "application/octet-stream";
1433    let mut payload = vec![0u8; 32];
1434    rand::thread_rng().fill_bytes(&mut payload[..]);
1435    let payload = Arc::new(payload);
1436    let payload_size = payload.len() as u64;
1437
1438    let mut probed = 0usize;
1439    for server_url_str in &server_urls {
1440        if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
1441            continue;
1442        }
1443        let parsed = match Url::parse(server_url_str) {
1444            Ok(u) => u,
1445            Err(_) => continue,
1446        };
1447        // 4s per-server budget bounds worst-case probe pass.
1448        let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
1449        match crate::rt::time::timeout(
1450            std::time::Duration::from_secs(4),
1451            upload_blob_with_progress(
1452                signer.clone(),
1453                &parsed,
1454                payload.clone(),
1455                Some(PROBE_MIME),
1456                no_op_progress,
1457                Some(0),
1458                None,
1459                None,
1460            ),
1461        ).await {
1462            Ok(Ok(url)) => {
1463                // Race-guard: server may have been disabled/removed
1464                // between spawn and now (purge_server already cleared).
1465                if !crate::blossom_servers::is_enabled_server(server_url_str) {
1466                    if let Some(hash) = extract_hash_from_blossom_url(&url) {
1467                        let _ = delete_blob(signer.clone(), &parsed, hash).await;
1468                    }
1469                    continue;
1470                }
1471                // Reaching here means the upload succeeded AND the returned hash
1472                // matched (upload_attempt's integrity gate) — so the server
1473                // accepts our encrypted type and stores it verbatim. Final gate:
1474                // it must honor BUD-01 deletion, else removing a message can't
1475                // remove its blob. The probe blob is deleted either way (cleanup);
1476                // a 4s budget bounds the wait, and only a definitive refusal
1477                // (403/405/501) sinks the server — transient failures stay optimistic.
1478                let delete_result = match extract_hash_from_blossom_url(&url) {
1479                    Some(hash) => crate::rt::time::timeout(
1480                        std::time::Duration::from_secs(4),
1481                        delete_blob(signer.clone(), &parsed, hash),
1482                    ).await.ok(),
1483                    None => None,
1484                };
1485                let refuses_deletion = matches!(
1486                    &delete_result,
1487                    Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)),
1488                );
1489                if refuses_deletion {
1490                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1491                        server_url_str, PROBE_MIME, true,
1492                    ) {
1493                        crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1494                    }
1495                    probed += 1;
1496                    crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str);
1497                } else {
1498                    if let Err(e) = crate::blossom_capabilities::record_accepted(
1499                        server_url_str, PROBE_MIME, true, payload_size,
1500                    ) {
1501                        crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
1502                    }
1503                    probed += 1;
1504                    crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str);
1505                }
1506            }
1507            Ok(Err(e)) => {
1508                // A transformed probe blob is as unsuitable as a hard MIME
1509                // rejection. A closed gate, a quota or a bad clock is not: the
1510                // server may take the next upload, so its reputation is left
1511                // alone and the probe runs again later.
1512                let unsuitable = matches!(e, UploadFailure::Integrity(_))
1513                    || e.refusal().is_some_and(|r| r.is_mime());
1514                if unsuitable {
1515                    if !crate::blossom_servers::is_enabled_server(server_url_str) {
1516                        continue;
1517                    }
1518                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1519                        server_url_str, PROBE_MIME, true,
1520                    ) {
1521                        crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1522                    }
1523                    probed += 1;
1524                    crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e);
1525                } else {
1526                    // Transient — leave reputation unchanged so we re-probe later.
1527                    crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
1528                }
1529            }
1530            Err(_) => {
1531                crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
1532            }
1533        }
1534    }
1535    Ok(probed)
1536}
1537
1538/// Parse the sha256 out of `<origin>/<sha256>[.<ext>][/]`. Skips an
1539/// empty trailing segment when the URL came back with a trailing slash.
1540fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
1541    let parsed = Url::parse(url).ok()?;
1542    let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
1543    let stem = last.split('.').next()?;
1544    Sha256Hash::from_str(stem).ok()
1545}
1546
1547/// Extract the HTTP status from an error string. Anchored to the
1548/// `"with status NNN"` shape produced by `upload_blob_with_progress`
1549/// so unrelated `status` substrings don't false-match.
1550fn parse_status_from_error(msg: &str) -> Option<u16> {
1551    let key = "with status ";
1552    let i = msg.find(key)?;
1553    let tail = &msg[i + key.len()..];
1554    let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
1555    digits.parse::<u16>().ok()
1556}
1557
1558#[cfg(test)]
1559mod parse_status_tests {
1560    use super::parse_status_from_error;
1561
1562    #[test]
1563    fn extracts_status_code() {
1564        assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
1565        assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
1566        assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
1567        // Cloudflare gateway timeouts render as "<unknown status code>" — the failover branch relies
1568        // on this still parsing to the numeric code.
1569        assert_eq!(parse_status_from_error("Upload failed with status 524 <unknown status code>: gateway"), Some(524));
1570    }
1571
1572    #[test]
1573    fn returns_none_when_absent() {
1574        assert_eq!(parse_status_from_error("network error: timeout"), None);
1575    }
1576}
1577
1578#[cfg(test)]
1579mod hash_swap_tests {
1580    use super::hash_swap_candidates;
1581
1582    const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1583
1584    #[test]
1585    fn derives_clean_leaves_and_skips_the_source_origin() {
1586        // Query + fragment on the primary must NOT leak into derived URLs,
1587        // the source origin is skipped, junk servers are dropped.
1588        let primary = format!("https://a.example/{}.png?utm=track#frag", HASH);
1589        let servers = vec![
1590            "https://a.example".to_string(),
1591            "https://b.example".to_string(),
1592            "not a url".to_string(),
1593        ];
1594        assert_eq!(
1595            hash_swap_candidates(&primary, &servers),
1596            vec![format!("https://b.example/{}.png", HASH)],
1597        );
1598    }
1599
1600    #[test]
1601    fn extensionless_primary_yields_the_bare_hash() {
1602        let primary = format!("https://a.example/{}", HASH);
1603        assert_eq!(
1604            hash_swap_candidates(&primary, &["https://b.example/".to_string()]),
1605            vec![format!("https://b.example/{}", HASH)],
1606        );
1607    }
1608
1609    #[test]
1610    fn unparseable_primary_yields_nothing() {
1611        assert!(hash_swap_candidates("https://a.example/not-a-hash.png", &["https://b.example".to_string()]).is_empty());
1612    }
1613}
1614
1615#[cfg(test)]
1616mod hash_extract_tests {
1617    use super::extract_hash_from_blossom_url;
1618
1619    const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1620
1621    #[test]
1622    fn plain_url() {
1623        let url = format!("https://srv.example/{}", HASH_HEX);
1624        assert!(extract_hash_from_blossom_url(&url).is_some());
1625    }
1626
1627    #[test]
1628    fn with_extension() {
1629        let url = format!("https://srv.example/{}.jpg", HASH_HEX);
1630        assert!(extract_hash_from_blossom_url(&url).is_some());
1631    }
1632
1633    #[test]
1634    fn trailing_slash_still_resolves() {
1635        // Some servers append a trailing slash to the descriptor URL.
1636        let url = format!("https://srv.example/{}/", HASH_HEX);
1637        assert!(extract_hash_from_blossom_url(&url).is_some());
1638    }
1639
1640    #[test]
1641    fn malformed_returns_none() {
1642        assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
1643        assert!(extract_hash_from_blossom_url("not a url").is_none());
1644        assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
1645    }
1646
1647    #[test]
1648    fn x_sha256_simd_hex_matches_lowerhex() {
1649        use bitcoin_hashes::sha256::Hash as Sha256Hash;
1650        // The X-SHA-256 header swapped format!("{:x}") for the SIMD encoder; they MUST agree
1651        // byte-for-byte (sha256::Hash displays in forward order — a reversed-display hash type would
1652        // silently corrupt the upload header).
1653        let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
1654        assert_eq!(
1655            crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
1656            format!("{:x}", hash),
1657        );
1658    }
1659}
1660
1661#[cfg(test)]
1662mod mirror_target_tests {
1663    use super::mirror_targets;
1664
1665    /// `parse_blob_url` demands a full 64-hex SHA-256, so the fixture carries
1666    /// a real one; a truncated hash makes every call return no targets.
1667    const BLOB: &str = "3ad3648700bc0000000000000000000000000000000000000000000000000000.bin";
1668
1669    fn servers(v: &[&str]) -> Vec<String> {
1670        v.iter().map(|s| s.to_string()).collect()
1671    }
1672
1673    fn names(t: Vec<url::Url>) -> Vec<String> {
1674        t.into_iter().map(|u| u.host_str().unwrap_or("").to_string()).collect()
1675    }
1676
1677    #[test]
1678    fn the_server_that_accepted_the_upload_is_not_a_mirror_target() {
1679        // The case observed in production: the bytes went to the regional
1680        // ingress name and the descriptor came back on the origin's canonical
1681        // name. Both are the same server, and neither is worth mirroring to.
1682        // Comparing the descriptor's host alone does NOT catch this — the two
1683        // strings differ — which is why the accepting address is passed in.
1684        let t = mirror_targets(
1685            &format!("https://magnitude.jskitty.com/{BLOB}"),
1686            &servers(&["https://asia.magnitude.jskitty.com", "https://blossom.band"]),
1687            2,
1688            &["https://asia.magnitude.jskitty.com".to_string()],
1689        );
1690        assert_eq!(names(t), vec!["blossom.band"]);
1691    }
1692
1693    #[test]
1694    fn genuinely_different_servers_are_still_mirrored_to() {
1695        let t = mirror_targets(
1696            &format!("https://magnitude.jskitty.com/{BLOB}"),
1697            &servers(&["https://blossom.band", "https://nostr.download"]),
1698            2,
1699            &["https://asia.magnitude.jskitty.com".to_string()],
1700        );
1701        assert_eq!(names(t), vec!["blossom.band", "nostr.download"]);
1702    }
1703
1704    #[test]
1705    fn the_source_origin_is_excluded_even_with_nothing_passed_in() {
1706        // The pre-existing guarantee, unchanged by the new argument.
1707        let t = mirror_targets(
1708            &format!("https://magnitude.jskitty.com/{BLOB}"),
1709            &servers(&["https://magnitude.jskitty.com", "https://blossom.band"]),
1710            2,
1711            &[],
1712        );
1713        assert_eq!(names(t), vec!["blossom.band"]);
1714    }
1715
1716    #[test]
1717    fn max_mirrors_is_applied_after_exclusion_not_before() {
1718        // Taking first and filtering second would yield nothing here, and the
1719        // message would ship with no fallback sources at all.
1720        let t = mirror_targets(
1721            &format!("https://magnitude.jskitty.com/{BLOB}"),
1722            &servers(&[
1723                "https://asia.magnitude.jskitty.com",
1724                "https://magnitude.jskitty.com",
1725                "https://blossom.band",
1726            ]),
1727            1,
1728            &["https://asia.magnitude.jskitty.com".to_string()],
1729        );
1730        assert_eq!(names(t), vec!["blossom.band"]);
1731    }
1732
1733    #[test]
1734    fn a_port_or_scheme_difference_is_a_different_server() {
1735        // Origin comparison, not host comparison: a self-hosted server on
1736        // another port is genuinely somewhere else.
1737        let t = mirror_targets(
1738            &format!("https://example.com/{BLOB}"),
1739            &servers(&["https://example.com:8443"]),
1740            2,
1741            &[],
1742        );
1743        assert_eq!(names(t), vec!["example.com"]);
1744    }
1745
1746    #[test]
1747    fn an_unparseable_source_mirrors_nowhere() {
1748        let t = mirror_targets("not a url", &servers(&["https://blossom.band"]), 2, &[]);
1749        assert!(t.is_empty());
1750    }
1751}
1752
1753#[cfg(test)]
1754mod stall_watch_tests {
1755    use super::{Stall, StallWatch};
1756    use std::time::Duration;
1757
1758    const STALL: Duration = Duration::from_secs(60);
1759    const RESPONSE: Duration = Duration::from_secs(300);
1760
1761    #[tokio::test(start_paused = true)]
1762    async fn a_transfer_moving_at_any_rate_is_never_abandoned() {
1763        // One 16 KB chunk every 50 s is under 3 kbit/s, and the watchdog is
1764        // content: 300 s of wall time here would have killed it under a deadline.
1765        let mut w = StallWatch::new(1 << 20, STALL, RESPONSE);
1766        let mut sent = 0u64;
1767        for _ in 0..8 {
1768            crate::rt::time::advance(Duration::from_secs(50)).await;
1769            sent += 16 * 1024;
1770            assert_eq!(w.observe(sent), None);
1771        }
1772    }
1773
1774    #[tokio::test(start_paused = true)]
1775    async fn a_transfer_that_stops_is_abandoned_after_the_window() {
1776        let mut w = StallWatch::new(1 << 20, STALL, RESPONSE);
1777        assert_eq!(w.observe(4096), None);
1778        crate::rt::time::advance(Duration::from_secs(59)).await;
1779        assert_eq!(w.observe(4096), None, "inside the window it is only slow");
1780        crate::rt::time::advance(Duration::from_secs(2)).await;
1781        assert!(matches!(w.observe(4096), Some(Stall::NoProgress { .. })));
1782    }
1783
1784    #[tokio::test(start_paused = true)]
1785    async fn progress_resets_the_window() {
1786        let mut w = StallWatch::new(1 << 20, STALL, RESPONSE);
1787        w.observe(100);
1788        crate::rt::time::advance(Duration::from_secs(55)).await;
1789        w.observe(200);
1790        crate::rt::time::advance(Duration::from_secs(55)).await;
1791        assert_eq!(w.observe(200), None, "the clock restarted at the second byte");
1792    }
1793
1794    #[tokio::test(start_paused = true)]
1795    async fn after_the_last_byte_the_server_gets_longer_but_not_forever() {
1796        let total = 1 << 20;
1797        let mut w = StallWatch::new(total, STALL, RESPONSE);
1798        w.observe(total);
1799        // Long enough for a relay to push a large blob to its origin.
1800        crate::rt::time::advance(Duration::from_secs(240)).await;
1801        assert_eq!(w.observe(total), None, "buffers drain and the blob is stored");
1802        crate::rt::time::advance(Duration::from_secs(61)).await;
1803        assert!(matches!(w.observe(total), Some(Stall::NoResponse { .. })));
1804    }
1805
1806    #[tokio::test(start_paused = true)]
1807    async fn an_empty_upload_is_waiting_on_the_server_from_the_start() {
1808        let mut w = StallWatch::new(0, STALL, RESPONSE);
1809        crate::rt::time::advance(STALL + Duration::from_secs(1)).await;
1810        assert_eq!(w.observe(0), None, "nothing to send is not a stall");
1811        crate::rt::time::advance(RESPONSE).await;
1812        assert!(matches!(w.observe(0), Some(Stall::NoResponse { .. })));
1813    }
1814}