max / makenotwork
2 files changed,
+41 insertions,
-6 deletions
| @@ -64,6 +64,19 @@ impl ScanTargetKind { | |||
| 64 | 64 | }) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | /// The S3 bucket this kind's objects live in. Single source of truth for | |
| 68 | + | /// the scan worker's client selection, quarantine delete, and durable-delete | |
| 69 | + | /// enqueue — all three MUST agree, or a quarantine deletes from the wrong | |
| 70 | + | /// bucket and idempotently "succeeds" while the malware persists (ultra-fuzz | |
| 71 | + | /// Run #24 Storage CRITICAL). OTA artifacts are the only synckit-bucket kind | |
| 72 | + | /// today; this mirrors the `S3_KEY_REFS` registry in `pending_s3_deletions`. | |
| 73 | + | pub fn storage_bucket(&self) -> crate::storage::S3Bucket { | |
| 74 | + | match self { | |
| 75 | + | ScanTargetKind::OtaArtifact => crate::storage::S3Bucket::Synckit, | |
| 76 | + | _ => crate::storage::S3Bucket::Main, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 67 | 80 | /// Whether this kind carries no `scan_status` column and is rendered | |
| 68 | 81 | /// straight from `cdn.makenot.work/{key}`, so a `Quarantined` verdict can | |
| 69 | 82 | /// only be enforced by deleting the *DB row* (which stops the URL from | |
| @@ -456,12 +469,29 @@ mod tests { | |||
| 456 | 469 | ScanTargetKind::ItemImage, | |
| 457 | 470 | ScanTargetKind::GalleryImage, | |
| 458 | 471 | ScanTargetKind::ContentInsertion, | |
| 472 | + | ScanTargetKind::OtaArtifact, | |
| 459 | 473 | ] { | |
| 460 | 474 | assert!(kind.quarantine_purges_object(), "{} must purge object", kind.as_str()); | |
| 461 | 475 | } | |
| 462 | 476 | } | |
| 463 | 477 | ||
| 464 | 478 | #[test] | |
| 479 | + | fn storage_bucket_matches_download_and_delete_path() { | |
| 480 | + | // Regression for ultra-fuzz Run #24 Storage CRITICAL: the quarantine | |
| 481 | + | // worker deleted OTA objects from the main bucket (idempotent success) | |
| 482 | + | // while the object persisted in the synckit bucket. `storage_bucket()` | |
| 483 | + | // is now the one source of truth the worker uses for the download | |
| 484 | + | // client, the delete, and the durable-delete enqueue — pin it here so a | |
| 485 | + | // new synckit-bucket kind can't silently default to Main. | |
| 486 | + | use crate::storage::S3Bucket; | |
| 487 | + | use ScanTargetKind::*; | |
| 488 | + | assert_eq!(OtaArtifact.storage_bucket(), S3Bucket::Synckit); | |
| 489 | + | for kind in [Item, Version, Media, ProjectImage, ItemImage, GalleryImage, ContentInsertion] { | |
| 490 | + | assert_eq!(kind.storage_bucket(), S3Bucket::Main, "{} is a main-bucket kind", kind.as_str()); | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | #[test] | |
| 465 | 495 | fn only_gateless_image_kinds_delete_their_row() { | |
| 466 | 496 | // The DB-row deletion is the gate-less-image-only half of enforcement. | |
| 467 | 497 | use ScanTargetKind::*; |
| @@ -307,14 +307,19 @@ async fn run_pipeline_and_decide( | |||
| 307 | 307 | // Holding it across the GET serializes downloads at SCAN_MAX_CONCURRENT | |
| 308 | 308 | // and lets a scan backlog starve the DB pool. | |
| 309 | 309 | // OTA artifacts live in the SyncKit bucket; everything else in the main | |
| 310 | - | // bucket. Pick the backend to download from by target kind. | |
| 311 | - | let backend: &Arc<dyn StorageBackend> = match kind { | |
| 312 | - | ScanTargetKind::OtaArtifact => ctx.synckit_s3.as_ref().ok_or_else(|| { | |
| 310 | + | // bucket. `kind.storage_bucket()` is the single source of truth: the same | |
| 311 | + | // value drives the download client here, the quarantine delete, and the | |
| 312 | + | // durable-delete enqueue below, so they can't diverge (ultra-fuzz Run #24 | |
| 313 | + | // Storage CRITICAL was a wrong-bucket quarantine delete from exactly that | |
| 314 | + | // divergence). | |
| 315 | + | let bucket = kind.storage_bucket(); | |
| 316 | + | let backend: &Arc<dyn StorageBackend> = match bucket { | |
| 317 | + | crate::storage::S3Bucket::Synckit => ctx.synckit_s3.as_ref().ok_or_else(|| { | |
| 313 | 318 | Box::<dyn std::error::Error + Send + Sync>::from( | |
| 314 | 319 | "SyncKit storage not configured; cannot scan OTA artifact", | |
| 315 | 320 | ) | |
| 316 | 321 | })?, | |
| 317 | - | _ => &ctx.s3, | |
| 322 | + | crate::storage::S3Bucket::Main => &ctx.s3, | |
| 318 | 323 | }; | |
| 319 | 324 | ||
| 320 | 325 | let result: ScanResult = if job.file_size_bytes as u64 > constants::SCAN_SPOOL_MAX_BYTES { | |
| @@ -429,7 +434,7 @@ async fn run_pipeline_and_decide( | |||
| 429 | 434 | // still-referenced gated key). Mint the authority the sealed delete | |
| 430 | 435 | // API requires. | |
| 431 | 436 | let auth = crate::storage::S3DeleteAuthority::new(); | |
| 432 | - | match ctx.s3.delete_object(&auth, &crate::storage::S3Key::from_stored(&job.s3_key)).await { | |
| 437 | + | match backend.delete_object(&auth, &crate::storage::S3Key::from_stored(&job.s3_key)).await { | |
| 433 | 438 | Ok(()) => { | |
| 434 | 439 | tracing::warn!( | |
| 435 | 440 | s3_key = %job.s3_key, target_kind = %kind.as_str(), | |
| @@ -452,7 +457,7 @@ async fn run_pipeline_and_decide( | |||
| 452 | 457 | if row_deleted { | |
| 453 | 458 | match db::pending_s3_deletions::enqueue_deletions( | |
| 454 | 459 | &ctx.db, | |
| 455 | - | &[(job.s3_key.clone(), "main".to_string())], | |
| 460 | + | &[(job.s3_key.clone(), bucket.as_str().to_string())], | |
| 456 | 461 | "malware_quarantine", | |
| 457 | 462 | ) | |
| 458 | 463 | .await |