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    std::time::SystemTime::now()
35        .duration_since(std::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    session: crate::state::SessionGuard,
57) -> Result<(), String> {
58    if !session.is_valid() { return Ok(()); }
59    let conn = crate::db::get_write_connection_guard_static()?;
60    if !session.is_valid() { return Ok(()); }
61    let server = norm_url(server_url);
62    let mime = mime_type.to_lowercase();
63    let enc = if is_encrypted { 1i64 } else { 0i64 };
64    let now = now_secs();
65    conn.execute(
66        "INSERT INTO blossom_server_capabilities
67            (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at)
68         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
69         ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
70            outcome = ?4,
71            max_accepted_size = MAX(max_accepted_size, ?5),
72            min_rejected_size = CASE
73                WHEN min_rejected_size IS NOT NULL AND min_rejected_size <= ?5
74                THEN NULL
75                ELSE min_rejected_size
76            END,
77            updated_at = ?6",
78        rusqlite::params![server, mime, enc, OUTCOME_ACCEPTED as i64, size_bytes as i64, now],
79    ).map_err(|e| format!("Failed to record accepted capability: {}", e))?;
80    Ok(())
81}
82
83/// Mark this `(server, mime, encrypted)` triple as MIME-rejected.
84/// Future uploads route around the server.
85pub fn record_rejected_mime(
86    server_url: &str,
87    mime_type: &str,
88    is_encrypted: bool,
89    session: crate::state::SessionGuard,
90) -> Result<(), String> {
91    if !session.is_valid() { return Ok(()); }
92    let conn = crate::db::get_write_connection_guard_static()?;
93    if !session.is_valid() { return Ok(()); }
94    let server = norm_url(server_url);
95    let mime = mime_type.to_lowercase();
96    let enc = if is_encrypted { 1i64 } else { 0i64 };
97    let now = now_secs();
98    conn.execute(
99        "INSERT INTO blossom_server_capabilities
100            (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at)
101         VALUES (?1, ?2, ?3, ?4, 0, ?5)
102         ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
103            outcome = ?4,
104            updated_at = ?5",
105        rusqlite::params![server, mime, enc, OUTCOME_REJECTED_MIME as i64, now],
106    ).map_err(|e| format!("Failed to record rejected capability: {}", e))?;
107    Ok(())
108}
109
110/// Record an HTTP 413. Tracks the smallest rejected size; combined with
111/// `max_accepted_size` this gives pre-flight "too large" feedback.
112/// Outcome is `SIZE_ONLY` (not `REJECTED_MIME`) — smaller blobs of the
113/// same MIME may still succeed.
114pub fn record_rejected_size(
115    server_url: &str,
116    mime_type: &str,
117    is_encrypted: bool,
118    size_bytes: u64,
119    session: crate::state::SessionGuard,
120) -> Result<(), String> {
121    if !session.is_valid() { return Ok(()); }
122    let conn = crate::db::get_write_connection_guard_static()?;
123    if !session.is_valid() { return Ok(()); }
124    let server = norm_url(server_url);
125    let mime = mime_type.to_lowercase();
126    let enc = if is_encrypted { 1i64 } else { 0i64 };
127    let now = now_secs();
128    // ON CONFLICT keeps any existing `outcome` (especially ACCEPTED) — a
129    // 413 above the known accepted size still leaves smaller blobs viable.
130    conn.execute(
131        "INSERT INTO blossom_server_capabilities
132            (server_url, mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at)
133         VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)
134         ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
135            min_rejected_size = MIN(COALESCE(min_rejected_size, ?5), ?5),
136            updated_at = ?6",
137        rusqlite::params![server, mime, enc, OUTCOME_SIZE_ONLY as i64, size_bytes as i64, now],
138    ).map_err(|e| format!("Failed to record size rejection: {}", e))?;
139    Ok(())
140}
141
142/// True iff a row exists for `(server, mime, encrypted)` and is younger
143/// than `STALE_AFTER_SECS`. Used by the probe scheduler to skip rows
144/// we already have current data for.
145pub fn has_fresh_capability_for(server_url: &str, mime_type: &str, is_encrypted: bool) -> bool {
146    let conn = match crate::db::get_db_connection_guard_static() {
147        Ok(c) => c,
148        Err(_) => return false,
149    };
150    let server = norm_url(server_url);
151    let mime = mime_type.to_lowercase();
152    let enc = if is_encrypted { 1i64 } else { 0i64 };
153    let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS);
154    conn.query_row(
155        "SELECT 1 FROM blossom_server_capabilities
156         WHERE server_url = ?1 AND mime_type = ?2 AND is_encrypted = ?3 AND updated_at >= ?4",
157        rusqlite::params![server, mime, enc, cutoff],
158        |_| Ok(()),
159    ).is_ok()
160}
161
162/// Drop every cached row for `server_url`. Called on hard-remove so a
163/// later re-add starts with a clean slate.
164pub fn purge_server(server_url: &str) -> Result<usize, String> {
165    let conn = crate::db::get_write_connection_guard_static()?;
166    let server = norm_url(server_url);
167    let n = conn.execute(
168        "DELETE FROM blossom_server_capabilities WHERE server_url = ?1",
169        rusqlite::params![server],
170    ).map_err(|e| format!("Failed to purge capabilities for {}: {}", server, e))?;
171    Ok(n)
172}
173
174/// All rows for `server_url`, most-recent first. Renders the info dialog.
175pub fn list_for_server(server_url: &str) -> Result<Vec<CapabilityEntry>, String> {
176    let conn = crate::db::get_db_connection_guard_static()?;
177    let server = norm_url(server_url);
178    let mut stmt = conn.prepare(
179        "SELECT mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at
180           FROM blossom_server_capabilities
181          WHERE server_url = ?1
182       ORDER BY updated_at DESC"
183    ).map_err(|e| format!("Prepare failed: {}", e))?;
184    let rows = stmt.query_map(rusqlite::params![server], |row| {
185        let enc: i64 = row.get(1)?;
186        let min_rej: Option<i64> = row.get(4)?;
187        Ok(CapabilityEntry {
188            mime_type: row.get(0)?,
189            is_encrypted: enc != 0,
190            outcome: row.get::<_, i64>(2)? as u8,
191            max_accepted_size: row.get::<_, i64>(3)? as u64,
192            min_rejected_size: min_rej.map(|v| v as u64),
193            updated_at: row.get(5)?,
194        })
195    }).map_err(|e| format!("Query failed: {}", e))?;
196    let mut out = Vec::new();
197    for r in rows {
198        if let Ok(entry) = r { out.push(entry); }
199    }
200    Ok(out)
201}
202
203// ============================================================================
204// Routing
205// ============================================================================
206
207/// Per-server snapshot used for tier classification.
208#[derive(Clone, Copy, Debug, PartialEq)]
209pub struct CapabilityState {
210    pub outcome: u8,
211    pub max_accepted_size: u64,
212    pub min_rejected_size: Option<u64>,
213}
214
215/// Pure tier-classification (DB-free, unit-testable). Reorders into
216/// known-good → unknown → too-large → MIME-rejected, stable within tier.
217pub fn classify(
218    cache: &HashMap<String, CapabilityState>,
219    servers: Vec<String>,
220    size_bytes: u64,
221) -> Vec<String> {
222    let mut known_good = Vec::new();
223    let mut unknown = Vec::new();
224    let mut too_large = Vec::new();
225    let mut mime_rejected = Vec::new();
226    for s in servers {
227        let key = norm_url(&s);
228        match cache.get(&key) {
229            Some(st) if st.outcome == OUTCOME_REJECTED_MIME => mime_rejected.push(s),
230            Some(st) => {
231                // At or above the known 413 ceiling, demote behind unknown.
232                if let Some(min_rej) = st.min_rejected_size {
233                    if size_bytes >= min_rej {
234                        too_large.push(s);
235                        continue;
236                    }
237                }
238                if size_bytes <= st.max_accepted_size {
239                    known_good.push(s);
240                } else {
241                    unknown.push(s);
242                }
243            }
244            None => unknown.push(s),
245        }
246    }
247    known_good.extend(unknown);
248    known_good.extend(too_large);
249    known_good.extend(mime_rejected);
250    known_good
251}
252
253/// Reorder `servers` for an upload of `(mime, encrypted, size_bytes)`.
254pub fn rank_servers(servers: Vec<String>, mime: &str, is_encrypted: bool, size_bytes: u64) -> Vec<String> {
255    if servers.is_empty() { return servers; }
256    let cache = load_cache_for(&servers, mime, is_encrypted).unwrap_or_default();
257    classify(&cache, servers, size_bytes)
258}
259
260/// Pre-flight check: is there any enabled server we haven't already
261/// learned will reject this size/MIME/context? Unknown servers count
262/// as "likely accepts" so we stay optimistic.
263pub fn any_server_likely_accepts(servers: &[String], mime: &str, is_encrypted: bool, size_bytes: u64) -> bool {
264    if servers.is_empty() { return false; }
265    let cache = match load_cache_for(servers, mime, is_encrypted) { Ok(c) => c, Err(_) => return true };
266    for s in servers {
267        let key = norm_url(s);
268        match cache.get(&key) {
269            Some(st) if st.outcome == OUTCOME_REJECTED_MIME => continue,
270            Some(st) => {
271                if let Some(min_rej) = st.min_rejected_size {
272                    if size_bytes >= min_rej { continue; }
273                }
274                return true;
275            }
276            None => return true,
277        }
278    }
279    false
280}
281
282fn load_cache_for(servers: &[String], mime: &str, is_encrypted: bool) -> Result<HashMap<String, CapabilityState>, String> {
283    if servers.is_empty() { return Ok(HashMap::new()); }
284    let conn = crate::db::get_db_connection_guard_static()?;
285    let mime_lower = mime.to_lowercase();
286    let enc: i64 = if is_encrypted { 1 } else { 0 };
287    // Stale rows route as unknown.
288    let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS);
289    // rusqlite doesn't accept slices in IN — build the clause manually.
290    let placeholders = servers.iter().enumerate()
291        .map(|(i, _)| format!("?{}", i + 4)).collect::<Vec<_>>().join(",");
292    let sql = format!(
293        "SELECT server_url, outcome, max_accepted_size, min_rejected_size
294           FROM blossom_server_capabilities
295          WHERE mime_type = ?1 AND is_encrypted = ?2 AND updated_at >= ?3 AND server_url IN ({})",
296        placeholders,
297    );
298    let mut stmt = conn.prepare(&sql).map_err(|e| format!("Prepare failed: {}", e))?;
299    let cutoff_param: i64 = cutoff;
300    let mut params: Vec<&dyn rusqlite::ToSql> = vec![&mime_lower, &enc, &cutoff_param];
301    let normalized: Vec<String> = servers.iter().map(|s| norm_url(s)).collect();
302    for n in normalized.iter() { params.push(n); }
303    let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
304        let url: String = row.get(0)?;
305        let outcome: i64 = row.get(1)?;
306        let max: i64 = row.get(2)?;
307        let min_rej: Option<i64> = row.get(3)?;
308        Ok((url, CapabilityState {
309            outcome: outcome as u8,
310            max_accepted_size: max as u64,
311            min_rejected_size: min_rej.map(|v| v as u64),
312        }))
313    }).map_err(|e| format!("Query failed: {}", e))?;
314    let mut out = HashMap::new();
315    for r in rows {
316        if let Ok((url, state)) = r {
317            out.insert(url, state);
318        }
319    }
320    Ok(out)
321}
322
323// ============================================================================
324// Outcome classification
325// ============================================================================
326
327/// HTTP 413 = "blob exceeds size cap". Drives `min_rejected_size`.
328pub fn is_size_rejection(http_status: Option<u16>) -> bool {
329    matches!(http_status, Some(413))
330}
331
332/// Classify an upload error as a permanent MIME rejection.
333///
334/// 415 is BUD-02's canonical signal. 401/402 are also treated permanent
335/// (Vector's auth is fixed-shape, so 401 won't change; 402 means paid
336/// server, we don't pay). 408/429/413/409 are explicitly NOT permanent.
337/// For 5xx and other 4xx with no clear status signal we fall back to a
338/// narrow body-keyword check — non-compliant servers (e.g. nostrcheck
339/// returning `500 "could not be processed"`, blossom.band's 400 sniff
340/// mismatch) would otherwise force a re-discover on every upload.
341pub fn is_mime_rejection(http_status: Option<u16>, error_msg: &str) -> bool {
342    if matches!(http_status, Some(415)) { return true; }
343    if matches!(http_status, Some(401)) { return true; }
344    if matches!(http_status, Some(402)) { return true; }
345    if matches!(http_status, Some(413) | Some(409)) { return false; }
346    if matches!(http_status, Some(408) | Some(429)) { return false; }
347    if let Some(s) = http_status {
348        if (500..=504).contains(&s) {
349            // Only the "could not process" idiom is permanent on 5xx —
350            // everything else stays transient (could be a temporary outage).
351            let lower = error_msg.to_ascii_lowercase();
352            return lower.contains("could not be processed")
353                || lower.contains("cannot be processed");
354        }
355    }
356    // Other 4xx or unknown status: substring-match body hints.
357    let lower = error_msg.to_ascii_lowercase();
358    let hints = [
359        "could not be processed",
360        "cannot be processed",
361        "unsupported",
362        "file type",
363        "mime",
364        "invalid file",
365        "not allowed",
366        "does not match",
367        "doesn't match",
368        "content-type",
369    ];
370    hints.iter().any(|h| lower.contains(h))
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn cache_of(entries: &[(&str, u8, u64)]) -> HashMap<String, CapabilityState> {
378        entries.iter()
379            .map(|(url, o, max)| (norm_url(url), CapabilityState {
380                outcome: *o,
381                max_accepted_size: *max,
382                min_rejected_size: None,
383            }))
384            .collect()
385    }
386
387    fn cache_with_size_cap(entries: &[(&str, u8, u64, u64)]) -> HashMap<String, CapabilityState> {
388        entries.iter()
389            .map(|(url, o, max, min_rej)| (norm_url(url), CapabilityState {
390                outcome: *o,
391                max_accepted_size: *max,
392                min_rejected_size: Some(*min_rej),
393            }))
394            .collect()
395    }
396
397    #[test]
398    fn norm_url_handles_case_and_trailing_slash() {
399        assert_eq!(norm_url("HTTPS://Example.COM/"), "https://example.com");
400        assert_eq!(norm_url("  https://example.com  "), "https://example.com");
401        assert_eq!(norm_url("https://example.com"), "https://example.com");
402    }
403
404    #[test]
405    fn classify_empty_input_returns_empty() {
406        let cache = HashMap::new();
407        assert!(classify(&cache, vec![], 1024).is_empty());
408    }
409
410    #[test]
411    fn classify_all_unknown_preserves_order() {
412        let cache = HashMap::new();
413        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
414        assert_eq!(classify(&cache, servers.clone(), 1024), servers);
415    }
416
417    #[test]
418    fn classify_known_good_floats_to_top() {
419        let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
420        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
421        assert_eq!(classify(&cache, servers, 5_000), vec!["https://b", "https://a", "https://c"]);
422    }
423
424    #[test]
425    fn classify_known_good_falls_to_unknown_when_over_size_ceiling() {
426        let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
427        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
428        let out = classify(&cache, servers, 20_000);
429        assert_eq!(out, vec!["https://a", "https://b", "https://c"]);
430    }
431
432    #[test]
433    fn classify_mime_rejected_sinks_to_bottom() {
434        let cache = cache_of(&[("https://b", OUTCOME_REJECTED_MIME, 0)]);
435        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
436        let out = classify(&cache, servers, 1024);
437        assert_eq!(out, vec!["https://a", "https://c", "https://b"]);
438    }
439
440    #[test]
441    fn classify_mixed_tiers_full_ordering() {
442        let cache = cache_of(&[
443            ("https://b", OUTCOME_ACCEPTED, 10_000),
444            ("https://c", OUTCOME_REJECTED_MIME, 0),
445            ("https://d", OUTCOME_ACCEPTED, 1_000),
446        ]);
447        let servers = vec![
448            "https://a".to_string(), "https://b".to_string(),
449            "https://c".to_string(), "https://d".to_string(),
450        ];
451        assert_eq!(
452            classify(&cache, servers, 5_000),
453            vec!["https://b", "https://a", "https://d", "https://c"],
454        );
455    }
456
457    #[test]
458    fn classify_demotes_servers_above_known_size_ceiling() {
459        let cache = cache_with_size_cap(&[("https://b", OUTCOME_ACCEPTED, 10_000, 50_000)]);
460        let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
461        let out = classify(&cache, servers, 60_000);
462        assert_eq!(out, vec!["https://a", "https://c", "https://b"]);
463    }
464
465    #[test]
466    fn any_server_likely_accepts_smoke_test() {
467        // Full fn needs DB access; this just sanity-checks the data shape.
468        let cache = cache_with_size_cap(&[
469            ("https://a", OUTCOME_ACCEPTED, 1_000, 10_000),
470            ("https://b", OUTCOME_ACCEPTED, 1_000, 10_000),
471        ]);
472        for (_, st) in &cache {
473            assert!(st.min_rejected_size.unwrap() <= 50_000);
474        }
475    }
476
477    #[test]
478    fn classify_keys_are_normalized_against_cache() {
479        let cache = cache_of(&[("https://b.example.com", OUTCOME_ACCEPTED, 10_000)]);
480        let servers = vec!["https://a".to_string(), "https://B.Example.com/".to_string()];
481        let out = classify(&cache, servers, 5_000);
482        assert_eq!(out, vec!["https://B.Example.com/", "https://a"]);
483    }
484
485    #[test]
486    fn mime_rejection_415_is_always_a_reject() {
487        assert!(is_mime_rejection(Some(415), ""));
488    }
489
490    #[test]
491    fn mime_rejection_413_and_409_never_mime_regardless_of_body() {
492        assert!(!is_mime_rejection(Some(413), "Payload Too Large mime"));
493        assert!(!is_mime_rejection(Some(409), "Conflict file type"));
494    }
495
496    #[test]
497    fn mime_rejection_401_treated_as_permanent() {
498        assert!(is_mime_rejection(Some(401), "Unauthorized"));
499        assert!(is_mime_rejection(Some(401), ""));
500    }
501
502    #[test]
503    fn mime_rejection_5xx_with_body_hint_recorded() {
504        // nostrcheck-shape: 500 + "could not be processed".
505        assert!(is_mime_rejection(
506            Some(500),
507            r#"Upload failed with status 500 Internal Server Error: {"status":"error","message":"File could not be processed"}"#,
508        ));
509    }
510
511    #[test]
512    fn mime_rejection_content_type_sniff_mismatch_recorded() {
513        // blossom.band-shape: 400 + body says the bytes didn't match the declared MIME.
514        assert!(is_mime_rejection(
515            Some(400),
516            "Upload failed with status 400 Bad Request: Content-Type header does not match the file content, expected application/json",
517        ));
518    }
519
520    #[test]
521    fn mime_rejection_transient_5xx_without_hint_not_recorded() {
522        assert!(!is_mime_rejection(Some(500), "Internal Server Error"));
523        assert!(!is_mime_rejection(Some(503), "service unavailable"));
524    }
525
526    #[test]
527    fn mime_rejection_transient_5xx_with_unrelated_body_keywords_not_recorded() {
528        // "file type" / "content-type" in a transient body must not demote permanently.
529        assert!(!is_mime_rejection(Some(503), "file type detection service down"));
530        assert!(!is_mime_rejection(Some(429), "rate limit reached for content-type uploads"));
531    }
532
533    #[test]
534    fn mime_rejection_payment_required_treated_as_permanent() {
535        assert!(is_mime_rejection(Some(402), ""));
536        assert!(is_mime_rejection(Some(402), "Payment Required"));
537    }
538
539    #[test]
540    fn mime_rejection_no_status_with_body_hint_recorded() {
541        assert!(is_mime_rejection(None, "Unsupported file type"));
542    }
543
544    #[test]
545    fn mime_rejection_no_status_no_body_hint_not_recorded() {
546        assert!(!is_mime_rejection(None, "network error"));
547        assert!(!is_mime_rejection(None, "timeout"));
548    }
549}