1use std::collections::HashMap;
7use serde::Serialize;
8
9pub const OUTCOME_ACCEPTED: u8 = 1;
10pub const OUTCOME_REJECTED_MIME: u8 = 2;
11pub const OUTCOME_SIZE_ONLY: u8 = 3;
14
15pub const STALE_AFTER_SECS: i64 = 4 * 24 * 3600;
18
19#[derive(Clone, Debug, Serialize)]
20pub struct CapabilityEntry {
21 pub mime_type: String,
22 pub is_encrypted: bool,
26 pub outcome: u8, pub max_accepted_size: u64,
28 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
43pub 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
83pub 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
110pub 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 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
142pub 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
162pub 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
174pub 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#[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
215pub 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 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
253pub 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
260pub 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 let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS);
289 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
323pub fn is_size_rejection(http_status: Option<u16>) -> bool {
329 matches!(http_status, Some(413))
330}
331
332pub 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 let lower = error_msg.to_ascii_lowercase();
352 return lower.contains("could not be processed")
353 || lower.contains("cannot be processed");
354 }
355 }
356 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 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 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 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 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}