Skip to main content

vector_core/
blossom_capabilities.rs

1//! Per-(server, mime, encrypted) Blossom capability cache and routing.
2//!
3//! Uploads report outcomes here; `rank_servers()` reorders the enabled
4//! list so known-good servers are tried first.
5
6use std::collections::HashMap;
7use serde::Serialize;
8
9pub const OUTCOME_ACCEPTED: u8 = 1;
10pub const OUTCOME_REJECTED_MIME: u8 = 2;
11/// Seeded by a 413-only rejection (no successful upload yet). Kept
12/// distinct from ACCEPTED so the UI doesn't claim an empty accepted state.
13pub const OUTCOME_SIZE_ONLY: u8 = 3;
14
15/// Rows older than this are routed as "unknown" and re-probed. Server
16/// policies drift (limit bumps, MIME allow-list edits) so we refresh.
17pub const STALE_AFTER_SECS: i64 = 4 * 24 * 3600;
18
19#[derive(Clone, Debug, Serialize)]
20pub struct CapabilityEntry {
21    pub mime_type: String,
22    /// Encrypted ciphertext rarely passes a server's content-sniff even
23    /// when the declared MIME is allowed. Rows split on this flag so the
24    /// two contexts learn independently.
25    pub is_encrypted: bool,
26    pub outcome: u8,            // 1=accepted, 2=rejected_mime, 3=size_only
27    pub max_accepted_size: u64,
28    /// Smallest size we've seen this server reject with HTTP 413, if any.
29    pub min_rejected_size: Option<u64>,
30    pub updated_at: i64,
31}
32
33fn now_secs() -> i64 {
34    web_time::SystemTime::now()
35        .duration_since(web_time::UNIX_EPOCH)
36        .map(|d| d.as_secs() as i64).unwrap_or(0)
37}
38
39fn norm_url(url: &str) -> String {
40    url.trim().trim_end_matches('/').to_lowercase()
41}
42
43// ============================================================================
44// Storage
45// ============================================================================
46
47/// Record a successful upload. Bumps `max_accepted_size`; clears
48/// `min_rejected_size` if reality contradicts it (the old rejection
49/// was a flake, not a policy). `session` pins the write to the
50/// account that started the upload so a mid-flight swap can't bleed.
51pub fn record_accepted(
52    server_url: &str,
53    mime_type: &str,
54    is_encrypted: bool,
55    size_bytes: u64,
56) -> Result<(), String> {
57    let conn = crate::db::get_write_connection_guard_static()?;
58    let server = norm_url(server_url);
59    let mime = mime_type.to_lowercase();
60    let enc = if is_encrypted { 1i64 } else { 0i64 };
61    let now = now_secs();
62    conn.execute(
63        "INSERT INTO blossom_server_capabilities
64            (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at)
65         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
66         ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
67            outcome = ?4,
68            max_accepted_size = MAX(max_accepted_size, ?5),
69            min_rejected_size = CASE
70                WHEN min_rejected_size IS NOT NULL AND min_rejected_size <= ?5
71                THEN NULL
72                ELSE min_rejected_size
73            END,
74            updated_at = ?6",
75        rusqlite::params![server, mime, enc, OUTCOME_ACCEPTED as i64, size_bytes as i64, now],
76    ).map_err(|e| format!("Failed to record accepted capability: {}", e))?;
77    Ok(())
78}
79
80/// Mark this `(server, mime, encrypted)` triple as MIME-rejected.
81/// Future uploads route around the server.
82pub fn record_rejected_mime(
83    server_url: &str,
84    mime_type: &str,
85    is_encrypted: bool,
86) -> Result<(), String> {
87    let conn = crate::db::get_write_connection_guard_static()?;
88    let server = norm_url(server_url);
89    let mime = mime_type.to_lowercase();
90    let enc = if is_encrypted { 1i64 } else { 0i64 };
91    let now = now_secs();
92    conn.execute(
93        "INSERT INTO blossom_server_capabilities
94            (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at)
95         VALUES (?1, ?2, ?3, ?4, 0, ?5)
96         ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
97            outcome = ?4,
98            updated_at = ?5",
99        rusqlite::params![server, mime, enc, OUTCOME_REJECTED_MIME as i64, now],
100    ).map_err(|e| format!("Failed to record rejected capability: {}", e))?;
101    Ok(())
102}
103
104/// Record an HTTP 413. Tracks the smallest rejected size; combined with
105/// `max_accepted_size` this gives pre-flight "too large" feedback.
106/// Outcome is `SIZE_ONLY` (not `REJECTED_MIME`) — smaller blobs of the
107/// same MIME may still succeed.
108pub fn record_rejected_size(
109    server_url: &str,
110    mime_type: &str,
111    is_encrypted: bool,
112    size_bytes: u64,
113) -> Result<(), String> {
114    let conn = crate::db::get_write_connection_guard_static()?;
115    let server = norm_url(server_url);
116    let mime = mime_type.to_lowercase();
117    let enc = if is_encrypted { 1i64 } else { 0i64 };
118    let now = now_secs();
119    // ON CONFLICT keeps any existing `outcome` (especially ACCEPTED) — a
120    // 413 above the known accepted size still leaves smaller blobs viable.
121    conn.execute(
122        "INSERT INTO blossom_server_capabilities
123            (server_url, mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at)
124         VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)
125         ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
126            min_rejected_size = MIN(COALESCE(min_rejected_size, ?5), ?5),
127            updated_at = ?6",
128        rusqlite::params![server, mime, enc, OUTCOME_SIZE_ONLY as i64, size_bytes as i64, now],
129    ).map_err(|e| format!("Failed to record size rejection: {}", e))?;
130    Ok(())
131}
132
133/// True iff a row exists for `(server, mime, encrypted)` and is younger
134/// than `STALE_AFTER_SECS`. Used by the probe scheduler to skip rows
135/// we already have current data for.
136pub fn has_fresh_capability_for(server_url: &str, mime_type: &str, is_encrypted: bool) -> bool {
137    let conn = match crate::db::get_db_connection_guard_static() {
138        Ok(c) => c,
139        Err(_) => return false,
140    };
141    let server = norm_url(server_url);
142    let mime = mime_type.to_lowercase();
143    let enc = if is_encrypted { 1i64 } else { 0i64 };
144    let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS);
145    conn.query_row(
146        "SELECT 1 FROM blossom_server_capabilities
147         WHERE server_url = ?1 AND mime_type = ?2 AND is_encrypted = ?3 AND updated_at >= ?4",
148        rusqlite::params![server, mime, enc, cutoff],
149        |_| Ok(()),
150    ).is_ok()
151}
152
153/// Whether a BUD-06 preflight to `server_url` could only say yes: a fresh upload of
154/// this kind at this size or larger went through, nothing this size has been refused
155/// since, and the server's own document for this account doesn't say no.
156pub fn preflight_redundant(server_url: &str, mime: &str, is_encrypted: bool, size_bytes: u64) -> bool {
157    let info = crate::blossom_info::cached(server_url).and_then(|d| d.accepts_size(size_bytes));
158    let servers = [server_url.to_string()];
159    let Ok(cache) = load_cache_for(&servers, mime, is_encrypted) else { return false };
160    known_to_take(cache.get(&norm_url(server_url)), info, size_bytes)
161}
162
163/// [`preflight_redundant`]'s rule over what is known, DB-free.
164fn known_to_take(state: Option<&CapabilityState>, info_verdict: Option<bool>, size_bytes: u64) -> bool {
165    info_verdict != Some(false)
166        && matches!(
167            state,
168            Some(st) if st.outcome == OUTCOME_ACCEPTED
169                && size_bytes <= st.max_accepted_size
170                && st.min_rejected_size.is_none_or(|m| size_bytes < m)
171        )
172}
173
174/// Drop every cached row for `server_url`. Called on hard-remove so a
175/// later re-add starts with a clean slate.
176pub fn purge_server(server_url: &str) -> Result<usize, String> {
177    let conn = crate::db::get_write_connection_guard_static()?;
178    let server = norm_url(server_url);
179    let n = conn.execute(
180        "DELETE FROM blossom_server_capabilities WHERE server_url = ?1",
181        rusqlite::params![server],
182    ).map_err(|e| format!("Failed to purge capabilities for {}: {}", server, e))?;
183    Ok(n)
184}
185
186/// All rows for `server_url`, most-recent first. Renders the info dialog.
187pub fn list_for_server(server_url: &str) -> Result<Vec<CapabilityEntry>, String> {
188    let conn = crate::db::get_db_connection_guard_static()?;
189    let server = norm_url(server_url);
190    let mut stmt = conn.prepare(
191        "SELECT mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at
192           FROM blossom_server_capabilities
193          WHERE server_url = ?1
194       ORDER BY updated_at DESC"
195    ).map_err(|e| format!("Prepare failed: {}", e))?;
196    let rows = stmt.query_map(rusqlite::params![server], |row| {
197        let enc: i64 = row.get(1)?;
198        let min_rej: Option<i64> = row.get(4)?;
199        Ok(CapabilityEntry {
200            mime_type: row.get(0)?,
201            is_encrypted: enc != 0,
202            outcome: row.get::<_, i64>(2)? as u8,
203            max_accepted_size: row.get::<_, i64>(3)? as u64,
204            min_rejected_size: min_rej.map(|v| v as u64),
205            updated_at: row.get(5)?,
206        })
207    }).map_err(|e| format!("Query failed: {}", e))?;
208    let mut out = Vec::new();
209    for entry in rows.flatten() { out.push(entry); }
210    Ok(out)
211}
212
213// ============================================================================
214// Routing
215// ============================================================================
216
217/// Per-server snapshot used for tier classification.
218#[derive(Clone, Copy, Debug, PartialEq)]
219pub struct CapabilityState {
220    pub outcome: u8,
221    pub max_accepted_size: u64,
222    pub min_rejected_size: Option<u64>,
223}
224
225/// Pure tier-classification (DB-free, unit-testable). Reorders into
226/// informed-yes → known-good → unknown → too-large → MIME-rejected →
227/// informed-no, stable within tier.
228///
229/// `verdicts` is what a server's own information document says about this
230/// size for this account. A server that has told us outranks one we have
231/// only learned by bouncing uploads, and one that has told us no goes last:
232/// its preflight would only confirm it.
233pub fn classify(
234    cache: &HashMap<String, CapabilityState>,
235    verdicts: &HashMap<String, bool>,
236    servers: Vec<String>,
237    size_bytes: u64,
238) -> Vec<String> {
239    let mut informed_yes = Vec::new();
240    let mut known_good = Vec::new();
241    let mut unknown = Vec::new();
242    let mut too_large = Vec::new();
243    let mut mime_rejected = Vec::new();
244    let mut informed_no = Vec::new();
245    for s in servers {
246        let key = norm_url(&s);
247        match verdicts.get(&key) {
248            Some(true) => { informed_yes.push(s); continue; }
249            Some(false) => { informed_no.push(s); continue; }
250            None => {}
251        }
252        match cache.get(&key) {
253            Some(st) if st.outcome == OUTCOME_REJECTED_MIME => mime_rejected.push(s),
254            Some(st) => {
255                // At or above the known 413 ceiling, demote behind unknown.
256                if let Some(min_rej) = st.min_rejected_size {
257                    if size_bytes >= min_rej {
258                        too_large.push(s);
259                        continue;
260                    }
261                }
262                if size_bytes <= st.max_accepted_size {
263                    known_good.push(s);
264                } else {
265                    unknown.push(s);
266                }
267            }
268            None => unknown.push(s),
269        }
270    }
271    informed_yes.extend(known_good);
272    informed_yes.extend(unknown);
273    informed_yes.extend(too_large);
274    informed_yes.extend(mime_rejected);
275    informed_yes.extend(informed_no);
276    informed_yes
277}
278
279/// What each server's information document says about `size_bytes`, keyed
280/// like the capability cache. Servers without a document are absent.
281fn info_verdicts(servers: &[String], size_bytes: u64) -> HashMap<String, bool> {
282    servers
283        .iter()
284        .filter_map(|s| {
285            let verdict = crate::blossom_info::cached(s)?.accepts_size(size_bytes)?;
286            Some((norm_url(s), verdict))
287        })
288        .collect()
289}
290
291/// Reorder `servers` for an upload of `(mime, encrypted, size_bytes)`.
292pub fn rank_servers(servers: Vec<String>, mime: &str, is_encrypted: bool, size_bytes: u64) -> Vec<String> {
293    if servers.is_empty() { return servers; }
294    let cache = load_cache_for(&servers, mime, is_encrypted).unwrap_or_default();
295    let verdicts = info_verdicts(&servers, size_bytes);
296    classify(&cache, &verdicts, servers, size_bytes)
297}
298
299/// Whether an upload is worth attempting, and if not, why each server would
300/// refuse it — in the words of its own document where it has one.
301#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
302pub struct UploadVerdict {
303    pub likely: bool,
304    pub reasons: Vec<String>,
305}
306
307/// Pre-flight check across the enabled servers. A server's document is
308/// believed outright; a server we have only learned from bounces counts
309/// against only when it has rejected this size or type before. Unknown
310/// servers count as "likely accepts" so the check stays optimistic.
311pub fn upload_verdict(servers: &[String], mime: &str, is_encrypted: bool, size_bytes: u64) -> UploadVerdict {
312    if servers.is_empty() {
313        return UploadVerdict {
314            likely: false,
315            reasons: vec!["No media server is configured.".to_string()],
316        };
317    }
318    let cache = load_cache_for(servers, mime, is_encrypted).unwrap_or_default();
319    let mut reasons = Vec::new();
320    for s in servers {
321        let host = crate::blossom_error::host_of(s);
322        if let Some(info) = crate::blossom_info::cached(s) {
323            match info.accepts_size(size_bytes) {
324                Some(true) => return UploadVerdict { likely: true, reasons: Vec::new() },
325                Some(false) => {
326                    if let Some(why) = info.refusal_reason(&host, size_bytes) {
327                        reasons.push(why);
328                    }
329                    continue;
330                }
331                None => {}
332            }
333        }
334        match cache.get(&norm_url(s)) {
335            Some(st) if st.outcome == OUTCOME_REJECTED_MIME => {
336                reasons.push(format!("{} doesn't accept {} files.", host, mime));
337            }
338            Some(st) if st.min_rejected_size.is_some_and(|min| size_bytes >= min) => {
339                reasons.push(format!(
340                    "{} has refused files of {} before.",
341                    host, crate::crypto::format_bytes(st.min_rejected_size.unwrap_or(size_bytes)),
342                ));
343            }
344            _ => return UploadVerdict { likely: true, reasons: Vec::new() },
345        }
346    }
347    UploadVerdict { likely: false, reasons }
348}
349
350/// `upload_verdict` as a yes/no.
351pub fn any_server_likely_accepts(servers: &[String], mime: &str, is_encrypted: bool, size_bytes: u64) -> bool {
352    upload_verdict(servers, mime, is_encrypted, size_bytes).likely
353}
354
355fn load_cache_for(servers: &[String], mime: &str, is_encrypted: bool) -> Result<HashMap<String, CapabilityState>, String> {
356    if servers.is_empty() { return Ok(HashMap::new()); }
357    let conn = crate::db::get_db_connection_guard_static()?;
358    let mime_lower = mime.to_lowercase();
359    let enc: i64 = if is_encrypted { 1 } else { 0 };
360    // Stale rows route as unknown.
361    let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS);
362    // rusqlite doesn't accept slices in IN — build the clause manually.
363    let placeholders = servers.iter().enumerate()
364        .map(|(i, _)| format!("?{}", i + 4)).collect::<Vec<_>>().join(",");
365    let sql = format!(
366        "SELECT server_url, outcome, max_accepted_size, min_rejected_size
367           FROM blossom_server_capabilities
368          WHERE mime_type = ?1 AND is_encrypted = ?2 AND updated_at >= ?3 AND server_url IN ({})",
369        placeholders,
370    );
371    let mut stmt = conn.prepare(&sql).map_err(|e| format!("Prepare failed: {}", e))?;
372    let cutoff_param: i64 = cutoff;
373    let mut params: Vec<&dyn rusqlite::ToSql> = vec![&mime_lower, &enc, &cutoff_param];
374    let normalized: Vec<String> = servers.iter().map(|s| norm_url(s)).collect();
375    for n in normalized.iter() { params.push(n); }
376    let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
377        let url: String = row.get(0)?;
378        let outcome: i64 = row.get(1)?;
379        let max: i64 = row.get(2)?;
380        let min_rej: Option<i64> = row.get(3)?;
381        Ok((url, CapabilityState {
382            outcome: outcome as u8,
383            max_accepted_size: max as u64,
384            min_rejected_size: min_rej.map(|v| v as u64),
385        }))
386    }).map_err(|e| format!("Query failed: {}", e))?;
387    let mut out = HashMap::new();
388    for (url, state) in rows.flatten() {
389        out.insert(url, state);
390    }
391    Ok(out)
392}
393
394// ============================================================================
395// Outcome classification
396// ============================================================================
397
398/// HTTP 413 = "blob exceeds size cap". Drives `min_rejected_size`.
399pub fn is_size_rejection(http_status: Option<u16>) -> bool {
400    matches!(http_status, Some(413))
401}
402
403/// Classify an upload error as a permanent MIME rejection.
404///
405/// 415 is BUD-02's canonical signal. 401/402 are also treated permanent
406/// (Vector's auth is fixed-shape, so 401 won't change; 402 means paid
407/// server, we don't pay). 408/429/413/409 are explicitly NOT permanent.
408/// For 5xx and other 4xx with no clear status signal we fall back to a
409/// narrow body-keyword check — non-compliant servers (e.g. nostrcheck
410/// returning `500 "could not be processed"`, blossom.band's 400 sniff
411/// mismatch) would otherwise force a re-discover on every upload.
412pub fn is_mime_rejection(http_status: Option<u16>, error_msg: &str) -> bool {
413    if matches!(http_status, Some(415)) { return true; }
414    if matches!(http_status, Some(401)) { return true; }
415    if matches!(http_status, Some(402)) { return true; }
416    if matches!(http_status, Some(413) | Some(409)) { return false; }
417    if matches!(http_status, Some(408) | Some(429)) { return false; }
418    if let Some(s) = http_status {
419        if (500..=504).contains(&s) {
420            // Only the "could not process" idiom is permanent on 5xx —
421            // everything else stays transient (could be a temporary outage).
422            let lower = error_msg.to_ascii_lowercase();
423            return lower.contains("could not be processed")
424                || lower.contains("cannot be processed");
425        }
426    }
427    // Other 4xx or unknown status: substring-match body hints.
428    let lower = error_msg.to_ascii_lowercase();
429    let hints = [
430        "could not be processed",
431        "cannot be processed",
432        "unsupported",
433        "file type",
434        "mime",
435        "invalid file",
436        "not allowed",
437        "does not match",
438        "doesn't match",
439        "content-type",
440    ];
441    hints.iter().any(|h| lower.contains(h))
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn cache_of(entries: &[(&str, u8, u64)]) -> HashMap<String, CapabilityState> {
449        entries.iter()
450            .map(|(url, o, max)| (norm_url(url), CapabilityState {
451                outcome: *o,
452                max_accepted_size: *max,
453                min_rejected_size: None,
454            }))
455            .collect()
456    }
457
458    fn cache_with_size_cap(entries: &[(&str, u8, u64, u64)]) -> HashMap<String, CapabilityState> {
459        entries.iter()
460            .map(|(url, o, max, min_rej)| (norm_url(url), CapabilityState {
461                outcome: *o,
462                max_accepted_size: *max,
463                min_rejected_size: Some(*min_rej),
464            }))
465            .collect()
466    }
467
468    #[test]
469    fn norm_url_handles_case_and_trailing_slash() {
470        assert_eq!(norm_url("HTTPS://Example.COM/"), "https://example.com");
471        assert_eq!(norm_url("  https://example.com  "), "https://example.com");
472        assert_eq!(norm_url("https://example.com"), "https://example.com");
473    }
474
475    #[test]
476    fn classify_empty_input_returns_empty() {
477        let cache = HashMap::new();
478        assert!(classify(&cache, &HashMap::new(), vec![], 1024).is_empty());
479    }
480
481    #[test]
482    fn classify_all_unknown_preserves_order() {
483        let cache = HashMap::new();
484        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
485        assert_eq!(classify(&cache, &HashMap::new(), servers.clone(), 1024), servers);
486    }
487
488    #[test]
489    fn classify_known_good_floats_to_top() {
490        let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
491        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
492        assert_eq!(classify(&cache, &HashMap::new(), servers, 5_000), vec!["https://b", "https://a", "https://c"]);
493    }
494
495    #[test]
496    fn classify_known_good_falls_to_unknown_when_over_size_ceiling() {
497        let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
498        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
499        let out = classify(&cache, &HashMap::new(), servers, 20_000);
500        assert_eq!(out, vec!["https://a", "https://b", "https://c"]);
501    }
502
503    #[test]
504    fn classify_mime_rejected_sinks_to_bottom() {
505        let cache = cache_of(&[("https://b", OUTCOME_REJECTED_MIME, 0)]);
506        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
507        let out = classify(&cache, &HashMap::new(), servers, 1024);
508        assert_eq!(out, vec!["https://a", "https://c", "https://b"]);
509    }
510
511    #[test]
512    fn classify_mixed_tiers_full_ordering() {
513        let cache = cache_of(&[
514            ("https://b", OUTCOME_ACCEPTED, 10_000),
515            ("https://c", OUTCOME_REJECTED_MIME, 0),
516            ("https://d", OUTCOME_ACCEPTED, 1_000),
517        ]);
518        let servers = vec![
519            "https://a".to_string(), "https://b".to_string(),
520            "https://c".to_string(), "https://d".to_string(),
521        ];
522        assert_eq!(
523            classify(&cache, &HashMap::new(), servers, 5_000),
524            vec!["https://b", "https://a", "https://d", "https://c"],
525        );
526    }
527
528    #[test]
529    fn classify_demotes_servers_above_known_size_ceiling() {
530        let cache = cache_with_size_cap(&[("https://b", OUTCOME_ACCEPTED, 10_000, 50_000)]);
531        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
532        let out = classify(&cache, &HashMap::new(), servers, 60_000);
533        assert_eq!(out, vec!["https://a", "https://c", "https://b"]);
534    }
535
536    #[test]
537    fn any_server_likely_accepts_smoke_test() {
538        // Full fn needs DB access; this just sanity-checks the data shape.
539        let cache = cache_with_size_cap(&[
540            ("https://a", OUTCOME_ACCEPTED, 1_000, 10_000),
541            ("https://b", OUTCOME_ACCEPTED, 1_000, 10_000),
542        ]);
543        for (_, st) in &cache {
544            assert!(st.min_rejected_size.unwrap() <= 50_000);
545        }
546    }
547
548    #[test]
549    fn a_server_whose_document_says_yes_outranks_everything_learned() {
550        let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
551        let verdicts: HashMap<String, bool> = [("https://c".to_string(), true)].into_iter().collect();
552        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
553        assert_eq!(classify(&cache, &verdicts, servers, 5_000), vec!["https://c", "https://b", "https://a"]);
554    }
555
556    #[test]
557    fn a_server_whose_document_says_no_goes_last_even_when_learned_good() {
558        let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
559        let verdicts: HashMap<String, bool> = [("https://b".to_string(), false)].into_iter().collect();
560        let servers = vec!["https://a".to_string(), "https://b".to_string()];
561        assert_eq!(classify(&cache, &verdicts, servers, 5_000), vec!["https://a", "https://b"]);
562    }
563
564    #[test]
565    fn classify_keys_are_normalized_against_cache() {
566        let cache = cache_of(&[("https://b.example.com", OUTCOME_ACCEPTED, 10_000)]);
567        let servers = vec!["https://a".to_string(), "https://B.Example.com/".to_string()];
568        let out = classify(&cache, &HashMap::new(), servers, 5_000);
569        assert_eq!(out, vec!["https://B.Example.com/", "https://a"]);
570    }
571
572    #[test]
573    fn mime_rejection_415_is_always_a_reject() {
574        assert!(is_mime_rejection(Some(415), ""));
575    }
576
577    #[test]
578    fn mime_rejection_413_and_409_never_mime_regardless_of_body() {
579        assert!(!is_mime_rejection(Some(413), "Payload Too Large mime"));
580        assert!(!is_mime_rejection(Some(409), "Conflict file type"));
581    }
582
583    #[test]
584    fn mime_rejection_401_treated_as_permanent() {
585        assert!(is_mime_rejection(Some(401), "Unauthorized"));
586        assert!(is_mime_rejection(Some(401), ""));
587    }
588
589    #[test]
590    fn mime_rejection_5xx_with_body_hint_recorded() {
591        // nostrcheck-shape: 500 + "could not be processed".
592        assert!(is_mime_rejection(
593            Some(500),
594            r#"Upload failed with status 500 Internal Server Error: {"status":"error","message":"File could not be processed"}"#,
595        ));
596    }
597
598    #[test]
599    fn mime_rejection_content_type_sniff_mismatch_recorded() {
600        // blossom.band-shape: 400 + body says the bytes didn't match the declared MIME.
601        assert!(is_mime_rejection(
602            Some(400),
603            "Upload failed with status 400 Bad Request: Content-Type header does not match the file content, expected application/json",
604        ));
605    }
606
607    #[test]
608    fn mime_rejection_transient_5xx_without_hint_not_recorded() {
609        assert!(!is_mime_rejection(Some(500), "Internal Server Error"));
610        assert!(!is_mime_rejection(Some(503), "service unavailable"));
611    }
612
613    #[test]
614    fn mime_rejection_transient_5xx_with_unrelated_body_keywords_not_recorded() {
615        // "file type" / "content-type" in a transient body must not demote permanently.
616        assert!(!is_mime_rejection(Some(503), "file type detection service down"));
617        assert!(!is_mime_rejection(Some(429), "rate limit reached for content-type uploads"));
618    }
619
620    #[test]
621    fn mime_rejection_payment_required_treated_as_permanent() {
622        assert!(is_mime_rejection(Some(402), ""));
623        assert!(is_mime_rejection(Some(402), "Payment Required"));
624    }
625
626    #[test]
627    fn mime_rejection_no_status_with_body_hint_recorded() {
628        assert!(is_mime_rejection(None, "Unsupported file type"));
629    }
630
631    #[test]
632    fn mime_rejection_no_status_no_body_hint_not_recorded() {
633        assert!(!is_mime_rejection(None, "network error"));
634        assert!(!is_mime_rejection(None, "timeout"));
635    }
636}
637
638#[cfg(test)]
639mod preflight_tests {
640    use super::*;
641
642    fn took(max: u64, min_rejected: Option<u64>) -> CapabilityState {
643        CapabilityState { outcome: OUTCOME_ACCEPTED, max_accepted_size: max, min_rejected_size: min_rejected }
644    }
645
646    #[test]
647    fn a_server_that_took_as_much_needs_no_asking() {
648        assert!(known_to_take(Some(&took(10_000, None)), None, 9_000));
649        assert!(known_to_take(Some(&took(10_000, None)), Some(true), 10_000));
650    }
651
652    #[test]
653    fn anything_unproven_is_asked_first() {
654        assert!(!known_to_take(None, Some(true), 1), "never uploaded there: the document alone is not proof");
655        assert!(!known_to_take(Some(&took(10_000, None)), None, 10_001), "larger than it has taken");
656        assert!(!known_to_take(Some(&took(10_000, Some(9_000))), None, 9_500), "a refusal at this size since");
657        let mime_refused = CapabilityState { outcome: OUTCOME_REJECTED_MIME, max_accepted_size: 10_000, min_rejected_size: None };
658        assert!(!known_to_take(Some(&mime_refused), None, 1));
659    }
660
661    #[test]
662    fn the_servers_own_no_overrides_its_history() {
663        assert!(!known_to_take(Some(&took(10_000, None)), Some(false), 1), "a quota or tier change the history can't know");
664    }
665}