max / makenotwork
1 file changed,
+120 insertions,
-30 deletions
| @@ -9,6 +9,27 @@ use crate::helpers::{format_file_size, format_price, get_initials}; | |||
| 9 | 9 | ||
| 10 | 10 | use super::*; | |
| 11 | 11 | ||
| 12 | + | /// Fail-closed cover gate: render the CDN-served cover URL only when the raw | |
| 13 | + | /// `cover_scan_status` column parses to exactly [`db::FileScanStatus::Clean`]. | |
| 14 | + | /// | |
| 15 | + | /// Every other status (pending/scanning/quarantined/held_for_review/error) and | |
| 16 | + | /// every *unrecognized* string — a typo like `"Clean"`/`"clean "`, or a future | |
| 17 | + | /// variant not yet handled — fails closed to `None`, hiding the cover from the | |
| 18 | + | /// CDN. Routing the decision through `FileScanStatus::from_str` means the | |
| 19 | + | /// `"clean"` sentinel lives in exactly one place (`impl_str_enum!(FileScanStatus …)`), | |
| 20 | + | /// not re-typed at each conversion site. | |
| 21 | + | /// | |
| 22 | + | /// This is the *sole* sanctioned cover-scan gate. The build-time guard | |
| 23 | + | /// [`cover_gate::cover_scan_status_never_compared_to_a_literal`] fails the build | |
| 24 | + | /// if any file reintroduces a bare `cover_scan_status == "…"` compare, so a 7th | |
| 25 | + | /// conversion cannot silently serve an unscanned cover. | |
| 26 | + | fn visible_cover_url(cover_scan_status: &str, cover_image_url: &Option<String>) -> Option<String> { | |
| 27 | + | match cover_scan_status.parse::<db::FileScanStatus>() { | |
| 28 | + | Ok(db::FileScanStatus::Clean) => cover_image_url.clone(), | |
| 29 | + | _ => None, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 12 | 33 | /// Format a duration in seconds as `H:MM:SS` or `M:SS`. | |
| 13 | 34 | fn format_duration(seconds: i32) -> String { | |
| 14 | 35 | let hours = seconds / 3600; | |
| @@ -281,11 +302,7 @@ impl Item { | |||
| 281 | 302 | // Fail-closed cover gate: render the CDN-served cover only once its | |
| 282 | 303 | // scan cleared. Pending/held covers are hidden everywhere this view | |
| 283 | 304 | // is used, OG cards included. | |
| 284 | - | cover_image_url: if db_item.cover_scan_status == "clean" { | |
| 285 | - | db_item.cover_image_url.clone() | |
| 286 | - | } else { | |
| 287 | - | None | |
| 288 | - | }, | |
| 305 | + | cover_image_url: visible_cover_url(&db_item.cover_scan_status, &db_item.cover_image_url), | |
| 289 | 306 | is_free, | |
| 290 | 307 | can_access, | |
| 291 | 308 | enable_license_keys: db_item.enable_license_keys, | |
| @@ -325,11 +342,10 @@ impl Item { | |||
| 325 | 342 | ItemContent::Audio { | |
| 326 | 343 | duration, | |
| 327 | 344 | duration_seconds: db_item.duration_seconds, | |
| 328 | - | cover_url: if db_item.cover_scan_status == "clean" { | |
| 329 | - | db_item.cover_image_url.clone() | |
| 330 | - | } else { | |
| 331 | - | None | |
| 332 | - | }, | |
| 345 | + | cover_url: visible_cover_url( | |
| 346 | + | &db_item.cover_scan_status, | |
| 347 | + | &db_item.cover_image_url, | |
| 348 | + | ), | |
| 333 | 349 | episode_number: db_item.episode_number.map(|n| n as u32), | |
| 334 | 350 | audio_s3_key: db_item.audio_s3_key.clone(), | |
| 335 | 351 | } | |
| @@ -339,11 +355,10 @@ impl Item { | |||
| 339 | 355 | ItemContent::Video { | |
| 340 | 356 | duration, | |
| 341 | 357 | duration_seconds: db_item.video_duration_seconds, | |
| 342 | - | cover_url: if db_item.cover_scan_status == "clean" { | |
| 343 | - | db_item.cover_image_url.clone() | |
| 344 | - | } else { | |
| 345 | - | None | |
| 346 | - | }, | |
| 358 | + | cover_url: visible_cover_url( | |
| 359 | + | &db_item.cover_scan_status, | |
| 360 | + | &db_item.cover_image_url, | |
| 361 | + | ), | |
| 347 | 362 | video_s3_key: db_item.video_s3_key.clone(), | |
| 348 | 363 | width: db_item.video_width, | |
| 349 | 364 | height: db_item.video_height, | |
| @@ -364,11 +379,7 @@ impl Item { | |||
| 364 | 379 | sales_count: db_item.sales_count as u32, | |
| 365 | 380 | tags: tags.iter().map(TagView::from).collect(), | |
| 366 | 381 | content, | |
| 367 | - | cover_image_url: if db_item.cover_scan_status == "clean" { | |
| 368 | - | db_item.cover_image_url.clone() | |
| 369 | - | } else { | |
| 370 | - | None | |
| 371 | - | }, | |
| 382 | + | cover_image_url: visible_cover_url(&db_item.cover_scan_status, &db_item.cover_image_url), | |
| 372 | 383 | is_free, | |
| 373 | 384 | can_access, | |
| 374 | 385 | enable_license_keys: db_item.enable_license_keys, | |
| @@ -399,11 +410,10 @@ impl Project { | |||
| 399 | 410 | item_count, | |
| 400 | 411 | project_type: db_project.project_type.to_string(), | |
| 401 | 412 | // Fail-closed cover gate: hide the CDN-served cover until its scan clears. | |
| 402 | - | cover_image_url: if db_project.cover_scan_status == "clean" { | |
| 403 | - | db_project.cover_image_url.clone() | |
| 404 | - | } else { | |
| 405 | - | None | |
| 406 | - | }, | |
| 413 | + | cover_image_url: visible_cover_url( | |
| 414 | + | &db_project.cover_scan_status, | |
| 415 | + | &db_project.cover_image_url, | |
| 416 | + | ), | |
| 407 | 417 | } | |
| 408 | 418 | } | |
| 409 | 419 | } | |
| @@ -420,11 +430,7 @@ impl From<&db::DbProjectWithItemCount> for Project { | |||
| 420 | 430 | item_count: p.item_count.clamp(0, u32::MAX as i64) as u32, | |
| 421 | 431 | project_type: p.project_type.to_string(), | |
| 422 | 432 | // Fail-closed cover gate: hide the CDN-served cover until its scan clears. | |
| 423 | - | cover_image_url: if p.cover_scan_status == "clean" { | |
| 424 | - | p.cover_image_url.clone() | |
| 425 | - | } else { | |
| 426 | - | None | |
| 427 | - | }, | |
| 433 | + | cover_image_url: visible_cover_url(&p.cover_scan_status, &p.cover_image_url), | |
| 428 | 434 | } | |
| 429 | 435 | } | |
| 430 | 436 | } | |
| @@ -840,6 +846,35 @@ mod tests { | |||
| 840 | 846 | use super::*; | |
| 841 | 847 | ||
| 842 | 848 | #[test] | |
| 849 | + | fn visible_cover_url_shows_cover_only_when_clean() { | |
| 850 | + | let url = Some("https://cdn.makenot.work/covers/abc.jpg".to_string()); | |
| 851 | + | assert_eq!(visible_cover_url("clean", &url), url); | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | #[test] | |
| 855 | + | fn visible_cover_url_hides_for_every_non_clean_status() { | |
| 856 | + | let url = Some("https://cdn.makenot.work/covers/abc.jpg".to_string()); | |
| 857 | + | for status in ["pending", "scanning", "quarantined", "held_for_review", "error"] { | |
| 858 | + | assert_eq!(visible_cover_url(status, &url), None, "leaked cover for {status}"); | |
| 859 | + | } | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | #[test] | |
| 863 | + | fn visible_cover_url_fails_closed_on_unrecognized_or_typo_status() { | |
| 864 | + | let url = Some("https://cdn.makenot.work/covers/abc.jpg".to_string()); | |
| 865 | + | // Capitalization / whitespace / future variants must not parse to Clean. | |
| 866 | + | for status in ["Clean", "clean ", " clean", "CLEAN", "", "approved", "ok"] { | |
| 867 | + | assert_eq!(visible_cover_url(status, &url), None, "leaked cover for {status:?}"); | |
| 868 | + | } | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | #[test] | |
| 872 | + | fn visible_cover_url_never_invents_a_url() { | |
| 873 | + | // A clean status with no stored cover still yields None. | |
| 874 | + | assert_eq!(visible_cover_url("clean", &None), None); | |
| 875 | + | } | |
| 876 | + | ||
| 877 | + | #[test] | |
| 843 | 878 | fn format_duration_zero() { | |
| 844 | 879 | assert_eq!(format_duration(0), "0:00"); | |
| 845 | 880 | } | |
| @@ -899,3 +934,58 @@ mod tests { | |||
| 899 | 934 | assert_eq!(format_duration(303), "5:03"); | |
| 900 | 935 | } | |
| 901 | 936 | } | |
| 937 | + | ||
| 938 | + | /// Build-time seal for the cover-scan gate (Run #23, finding F1). The CDN-served | |
| 939 | + | /// cover URL must be gated *only* through [`visible_cover_url`], which routes the | |
| 940 | + | /// decision through the [`db::FileScanStatus`] enum. A bare | |
| 941 | + | /// `cover_scan_status == "clean"` string compare at a conversion site is the | |
| 942 | + | /// exact failure the enum removes: a `"Clean"`/`"clean "` typo, or a 7th site | |
| 943 | + | /// added without the check, silently serves an unscanned/quarantined cover. | |
| 944 | + | /// | |
| 945 | + | /// This mirrors the `routes_never_delete_s3_directly` seal: the build fails if | |
| 946 | + | /// any file under `src/` compares `cover_scan_status` to a string literal. | |
| 947 | + | #[cfg(test)] | |
| 948 | + | mod cover_gate { | |
| 949 | + | use std::path::Path; | |
| 950 | + | ||
| 951 | + | #[test] | |
| 952 | + | fn cover_scan_status_never_compared_to_a_literal() { | |
| 953 | + | // Built at runtime so this guard file does not match its own needle. | |
| 954 | + | let needle = format!("cover_scan_status {}", "== \""); | |
| 955 | + | let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); | |
| 956 | + | let mut offenders = Vec::new(); | |
| 957 | + | walk(&src_dir, &mut |path, contents| { | |
| 958 | + | for (i, line) in contents.lines().enumerate() { | |
| 959 | + | if line.trim_start().starts_with("//") { | |
| 960 | + | continue; // doc/comment lines legitimately mention the pattern | |
| 961 | + | } | |
| 962 | + | if line.contains(&needle) { | |
| 963 | + | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 964 | + | } | |
| 965 | + | } | |
| 966 | + | }); | |
| 967 | + | assert!( | |
| 968 | + | offenders.is_empty(), | |
| 969 | + | "cover-scan gate seal violated — gate the CDN cover through \ | |
| 970 | + | types::conversions::visible_cover_url (which parses FileScanStatus), \ | |
| 971 | + | never a bare cover_scan_status string compare. Offending lines:\n{}", | |
| 972 | + | offenders.join("\n") | |
| 973 | + | ); | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { | |
| 977 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 978 | + | return; | |
| 979 | + | }; | |
| 980 | + | for entry in entries.flatten() { | |
| 981 | + | let path = entry.path(); | |
| 982 | + | if path.is_dir() { | |
| 983 | + | walk(&path, f); | |
| 984 | + | } else if path.extension().is_some_and(|e| e == "rs") | |
| 985 | + | && let Ok(contents) = std::fs::read_to_string(&path) | |
| 986 | + | { | |
| 987 | + | f(&path, &contents); | |
| 988 | + | } | |
| 989 | + | } | |
| 990 | + | } | |
| 991 | + | } |