max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 files changed,
+758 insertions,
-142 deletions
| @@ -530,11 +530,16 @@ | |||
| 530 | 530 | /// its content-addressed [`content_key`](Self::content_key) and deletes the | |
| 531 | 531 | /// staging object. The upload's extension is carried in the staging key so | |
| 532 | 532 | /// the worker can build the content key without re-reading the entity row. | |
| 533 | - | /// Format: `staging/{uuid}.{ext}`. The random uuid means a replayed presigned | |
| 534 | - | /// PUT can only re-write the (unserved, post-scan-deleted) staging object, | |
| 535 | - | /// never the served content key (ultra-fuzz Run #24 Storage HIGH). | |
| 533 | + | /// Format: `staging/{uuid}/{sanitized_filename}`. The random uuid segment | |
| 534 | + | /// means a replayed presigned PUT can only re-write the (unserved, | |
| 535 | + | /// post-scan-deleted) staging object, never the served content key (ultra-fuzz | |
| 536 | + | /// Run #24 Storage HIGH), and two uploads of the same filename never collide. | |
| 537 | + | /// The original filename is preserved after the uuid so a confirm can recover | |
| 538 | + | /// it (e.g. a version download's suggested name) — `sanitize_filename` strips | |
| 539 | + | /// any `/`, so the name can't add path segments or escape the `staging/` | |
| 540 | + | /// prefix. The extension still rides along for the content key. | |
| 536 | 541 | pub fn generate_staging_key(filename: &str) -> S3Key { | |
| 537 | - | S3Key(format!("staging/{}.{}", uuid::Uuid::new_v4(), extension_for(filename))) | |
| 542 | + | S3Key(format!("staging/{}/{}", uuid::Uuid::new_v4(), sanitize_filename(filename))) | |
| 538 | 543 | } | |
| 539 | 544 | ||
| 540 | 545 | /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's | |
| @@ -895,6 +900,10 @@ | |||
| 895 | 900 | /// filename can't bloat the key. Content keys carry an extension purely so | |
| 896 | 901 | /// CDN-served objects keep a sensible suffix (content-type sniffing, browser | |
| 897 | 902 | /// "save as"); the hash is the identity, the extension is cosmetic. | |
| 903 | + | // Retained as a tested key-extension utility; `generate_staging_key` now embeds | |
| 904 | + | // the full sanitized filename (which carries the extension) instead of calling | |
| 905 | + | // this, so it has no production caller today. | |
| 906 | + | #[allow(dead_code)] | |
| 898 | 907 | pub(crate) fn extension_for(filename: &str) -> String { | |
| 899 | 908 | let ext: String = std::path::Path::new(filename) | |
| 900 | 909 | .extension() | |
| @@ -909,11 +918,8 @@ | |||
| 909 | 918 | } | |
| 910 | 919 | ||
| 911 | 920 | /// The extension segment of a key's basename (the text after the last `.`), or | |
| 912 | - | /// `"bin"`. Lets the scan worker carry a `staging/{uuid}.{ext}` object's | |
| 921 | + | /// `"bin"`. Lets the scan worker's promote step carry a staging object's | |
| 913 | 922 | /// extension onto its content key without re-reading the entity row. | |
| 914 | - | // Consumed by the scan worker's copy-on-clean step (Phase 2 of the | |
| 915 | - | // content-addressed scan-integrity change); tested now as a Phase 1 foundation. | |
| 916 | - | #[allow(dead_code)] | |
| 917 | 923 | pub(crate) fn key_extension(key: &str) -> &str { | |
| 918 | 924 | key.rsplit('/') | |
| 919 | 925 | .next() | |
| @@ -1689,3 +1695,83 @@ | |||
| 1689 | 1695 | } | |
| 1690 | 1696 | } | |
| 1691 | 1697 | } | |
| 1698 | + | ||
| 1699 | + | /// C1 scan-then-promote seal: route handlers must never mint a *served* S3 key. | |
| 1700 | + | /// | |
| 1701 | + | /// A presigned client upload can only ever land at a `staging/{uuid}` key | |
| 1702 | + | /// ([`S3Client::generate_staging_key`]); the served, content-addressed key | |
| 1703 | + | /// ([`S3Client::content_key`]) is created in exactly one place — the scan | |
| 1704 | + | /// worker's promote step, after a Clean verdict — so the bytes a buyer is served | |
| 1705 | + | /// are provably the bytes that were scanned. The mutable-served-key class (a | |
| 1706 | + | /// presign minting `{user}/{item}/type/filename`, then the owner re-PUTting to it | |
| 1707 | + | /// after it goes Clean) is what this closes. | |
| 1708 | + | /// | |
| 1709 | + | /// This guard fails the build if any file under `src/routes/` names a served-key | |
| 1710 | + | /// generator or `content_key`. It is stronger than `pub(crate)` visibility — | |
| 1711 | + | /// route code lives in the same crate, so `pub(crate)` would not stop it from | |
| 1712 | + | /// calling these — and it is the same grep-proof discipline as the delete seal | |
| 1713 | + | /// above. (The build runner uploads OTA artifacts server-side to a deterministic | |
| 1714 | + | /// key via `generate_ota_artifact_key`; it lives outside `src/routes/`, so it is | |
| 1715 | + | /// legitimately unaffected.) | |
| 1716 | + | #[cfg(test)] | |
| 1717 | + | mod served_key_seal_guard { | |
| 1718 | + | use std::path::Path; | |
| 1719 | + | ||
| 1720 | + | #[test] | |
| 1721 | + | fn routes_never_mint_served_keys() { | |
| 1722 | + | let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes"); | |
| 1723 | + | // Every served-key generator, plus the content-key minter. `staging` | |
| 1724 | + | // keys are the ONLY key a route may mint, so `generate_staging_key` is | |
| 1725 | + | // deliberately absent from this list. | |
| 1726 | + | // Anchored to the `S3Client::` call prefix so an unrelated `generate_key` | |
| 1727 | + | // (e.g. `license_keys::generate_key`, `helpers::generate_key_code`) is not | |
| 1728 | + | // a false positive — only the storage generators are S3Client methods. | |
| 1729 | + | const FORBIDDEN: &[&str] = &[ | |
| 1730 | + | "S3Client::generate_key(", | |
| 1731 | + | "S3Client::generate_version_key(", | |
| 1732 | + | "S3Client::generate_insertion_key(", | |
| 1733 | + | "S3Client::generate_media_key(", | |
| 1734 | + | "S3Client::generate_project_image_key(", | |
| 1735 | + | "S3Client::generate_ota_artifact_key(", | |
| 1736 | + | "S3Client::generate_item_gallery_key(", | |
| 1737 | + | "S3Client::generate_project_gallery_key(", | |
| 1738 | + | "S3Client::content_key(", | |
| 1739 | + | ]; | |
| 1740 | + | let mut offenders = Vec::new(); | |
| 1741 | + | walk(&routes_dir, &mut |path, contents| { | |
| 1742 | + | for (i, line) in contents.lines().enumerate() { | |
| 1743 | + | if line.trim_start().starts_with("//") { | |
| 1744 | + | continue; | |
| 1745 | + | } | |
| 1746 | + | for needle in FORBIDDEN { | |
| 1747 | + | if line.contains(needle) { | |
| 1748 | + | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 1749 | + | } | |
| 1750 | + | } | |
| 1751 | + | } | |
| 1752 | + | }); | |
| 1753 | + | assert!( | |
| 1754 | + | offenders.is_empty(), | |
| 1755 | + | "C1 seal violated — route handlers must presign only `generate_staging_key`; \ | |
| 1756 | + | the served/content key is minted solely by the scan worker's promote step. \ | |
| 1757 | + | Offending lines:\n{}", | |
| 1758 | + | offenders.join("\n") | |
| 1759 | + | ); | |
| 1760 | + | } | |
| 1761 | + | ||
| 1762 | + | fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { | |
| 1763 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 1764 | + | return; | |
| 1765 | + | }; | |
| 1766 | + | for entry in entries.flatten() { | |
| 1767 | + | let path = entry.path(); | |
| 1768 | + | if path.is_dir() { | |
| 1769 | + | walk(&path, f); | |
| 1770 | + | } else if path.extension().is_some_and(|e| e == "rs") | |
| 1771 | + | && let Ok(contents) = std::fs::read_to_string(&path) | |
| 1772 | + | { | |
| 1773 | + | f(&path, &contents); | |
| 1774 | + | } | |
| 1775 | + | } | |
| 1776 | + | } | |
| 1777 | + | } |
| @@ -60,6 +60,32 @@ | |||
| 60 | 60 | Ok(()) | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | /// Whether `s3_key` (in `bucket`) is a pending upload this user presigned. | |
| 64 | + | /// | |
| 65 | + | /// The confirm handlers used to prove ownership by checking the client-supplied | |
| 66 | + | /// key started with the user's `{user_id}/{item_id}/…` prefix. With scan-then- | |
| 67 | + | /// promote the presigned key is an owner-less `staging/{uuid}.{ext}`, so that | |
| 68 | + | /// structural check no longer binds the key to a user. Every presign records the | |
| 69 | + | /// key here against its owner (`record_pending_upload`); confirm now proves | |
| 70 | + | /// ownership by looking it up — a caller cannot confirm a staging key it did not | |
| 71 | + | /// presign (and cannot guess another user's random staging uuid). | |
| 72 | + | pub async fn is_owned( | |
| 73 | + | pool: &PgPool, | |
| 74 | + | user_id: UserId, | |
| 75 | + | s3_key: &str, | |
| 76 | + | bucket: &str, | |
| 77 | + | ) -> Result<bool> { | |
| 78 | + | let owned = sqlx::query_scalar::<_, bool>( | |
| 79 | + | "SELECT EXISTS (SELECT 1 FROM pending_uploads WHERE s3_key = $1 AND user_id = $2 AND bucket = $3)", | |
| 80 | + | ) | |
| 81 | + | .bind(s3_key) | |
| 82 | + | .bind(user_id) | |
| 83 | + | .bind(bucket) | |
| 84 | + | .fetch_one(pool) | |
| 85 | + | .await?; | |
| 86 | + | Ok(owned) | |
| 87 | + | } | |
| 88 | + | ||
| 63 | 89 | /// Per-tick cap on the orphan-upload reaper. The reaper runs every scheduler | |
| 64 | 90 | /// tick under the tick-wide advisory lock and deletes serially (one S3 round- | |
| 65 | 91 | /// trip per row), so an unbounded result set lets a backlog wedge the tick |
| @@ -240,6 +240,124 @@ | |||
| 240 | 240 | Ok(()) | |
| 241 | 241 | } | |
| 242 | 242 | ||
| 243 | + | /// Most recent content hash recorded for a key, from the scan-result audit | |
| 244 | + | /// trail. The scan worker computes and stores the sha256 in `file_scan_results` | |
| 245 | + | /// at scan time; the admin-approve promote path (which flips a held file to | |
| 246 | + | /// Clean and must copy it to its content-addressed key) has only the staging | |
| 247 | + | /// key in scope, so it reads the hash back here. Empty hashes — recorded by a | |
| 248 | + | /// degraded/held scan that never fully hashed the object — are skipped, so a | |
| 249 | + | /// content key is never derived from a blank digest. | |
| 250 | + | #[tracing::instrument(skip_all)] | |
| 251 | + | pub async fn latest_sha256_by_key( | |
| 252 | + | db: &PgPool, | |
| 253 | + | s3_key: &str, | |
| 254 | + | ) -> Result<Option<String>, sqlx::Error> { | |
| 255 | + | sqlx::query_scalar::<_, String>( | |
| 256 | + | "SELECT sha256 FROM file_scan_results \ | |
| 257 | + | WHERE s3_key = $1 AND sha256 <> '' \ | |
| 258 | + | ORDER BY scanned_at DESC LIMIT 1", | |
| 259 | + | ) | |
| 260 | + | .bind(s3_key) | |
| 261 | + | .fetch_optional(db) | |
| 262 | + | .await | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// Repoint a GATED entity (Item audio/video, Version, Media, OTA artifact) from | |
| 266 | + | /// its staging key to the immutable content key and mark it Clean in one write. | |
| 267 | + | /// | |
| 268 | + | /// This is the scan-then-promote closing move for the kinds that carry their own | |
| 269 | + | /// `scan_status` gate. The `(table, key column)` pair is a compile-time constant | |
| 270 | + | /// selected by `(kind, file_type)` — never user input — so formatting it into the | |
| 271 | + | /// statement is safe. Uses the runtime `query` (not the `!` macro) so extending | |
| 272 | + | /// the promote set needs no offline-cache regeneration. Accepts any executor so | |
| 273 | + | /// the caller can run it inside the same transaction as the staging-key delete | |
| 274 | + | /// enqueue. | |
| 275 | + | pub async fn promote_gated<'e>( | |
| 276 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 277 | + | kind: crate::db::scan_jobs::ScanTargetKind, | |
| 278 | + | file_type: crate::storage::FileType, | |
| 279 | + | target_id: Uuid, | |
| 280 | + | content_key: &str, | |
| 281 | + | ) -> Result<(), sqlx::Error> { | |
| 282 | + | use crate::db::scan_jobs::ScanTargetKind as K; | |
| 283 | + | use crate::storage::FileType as F; | |
| 284 | + | let sql: &'static str = match (kind, file_type) { | |
| 285 | + | (K::Item, F::Audio) => { | |
| 286 | + | "UPDATE items SET audio_s3_key = $1, scan_status = 'clean', updated_at = NOW() WHERE id = $2" | |
| 287 | + | } | |
| 288 | + | (K::Item, F::Video) => { | |
| 289 | + | "UPDATE items SET video_s3_key = $1, scan_status = 'clean', updated_at = NOW() WHERE id = $2" | |
| 290 | + | } | |
| 291 | + | (K::Version, _) => "UPDATE versions SET s3_key = $1, scan_status = 'clean' WHERE id = $2", | |
| 292 | + | (K::Media, _) => "UPDATE media_files SET s3_key = $1, scan_status = 'clean' WHERE id = $2", | |
| 293 | + | (K::OtaArtifact, _) => { | |
| 294 | + | "UPDATE ota_artifacts SET s3_key = $1, scan_status = 'clean' WHERE id = $2" | |
| 295 | + | } | |
| 296 | + | (other_kind, other_ft) => { | |
| 297 | + | // A CDN-image kind (or an Item file role with no key column) must go | |
| 298 | + | // through `promote_cdn_image_by_key` instead; reaching here is a wiring | |
| 299 | + | // bug, not a data condition. | |
| 300 | + | return Err(sqlx::Error::Protocol(format!( | |
| 301 | + | "promote_gated: {other_kind:?}/{other_ft:?} is not a gated promote target" | |
| 302 | + | ))); | |
| 303 | + | } | |
| 304 | + | }; | |
| 305 | + | sqlx::query(sql) | |
| 306 | + | .bind(content_key) | |
| 307 | + | .bind(target_id) | |
| 308 | + | .execute(executor) | |
| 309 | + | .await?; | |
| 310 | + | Ok(()) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// Repoint every CDN-served image surface that currently references `staging_key` | |
| 314 | + | /// to the immutable `content_key` (and its rebuilt public `content_url`), marking | |
| 315 | + | /// the row Clean. Mirrors [`set_cdn_image_scan_status_by_key`]'s surface list so | |
| 316 | + | /// the promote and the status-stamp can't drift apart. Only the one surface whose | |
| 317 | + | /// key matches is touched; the others no-op. `content_insertions` carries no URL | |
| 318 | + | /// column (it is served presigned, not straight from the CDN) so only its key and | |
| 319 | + | /// status move. Returns the number of rows repointed (expected: exactly 1). | |
| 320 | + | pub async fn promote_cdn_image_by_key( | |
| 321 | + | conn: &mut sqlx::PgConnection, | |
| 322 | + | staging_key: &str, | |
| 323 | + | content_key: &str, | |
| 324 | + | content_url: &str, | |
| 325 | + | ) -> Result<u64, sqlx::Error> { | |
| 326 | + | let mut affected = 0u64; | |
| 327 | + | // (SQL, binds_content_url) — the surfaces with a materialized public URL take | |
| 328 | + | // three binds (key, url, where-key); content_insertions takes two. | |
| 329 | + | for (sql, has_url) in [ | |
| 330 | + | ( | |
| 331 | + | "UPDATE item_images SET s3_key = $1, image_url = $2, scan_status = 'clean' WHERE s3_key = $3", | |
| 332 | + | true, | |
| 333 | + | ), | |
| 334 | + | ( | |
| 335 | + | "UPDATE project_images SET s3_key = $1, image_url = $2, scan_status = 'clean' WHERE s3_key = $3", | |
| 336 | + | true, | |
| 337 | + | ), | |
| 338 | + | ( | |
| 339 | + | "UPDATE items SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean' WHERE cover_s3_key = $3", | |
| 340 | + | true, | |
| 341 | + | ), | |
| 342 | + | ( | |
| 343 | + | "UPDATE projects SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean' WHERE cover_s3_key = $3", | |
| 344 | + | true, | |
| 345 | + | ), | |
| 346 | + | ( | |
| 347 | + | "UPDATE content_insertions SET storage_key = $1, scan_status = 'clean' WHERE storage_key = $2", | |
| 348 | + | false, | |
| 349 | + | ), | |
| 350 | + | ] { | |
| 351 | + | let q = if has_url { | |
| 352 | + | sqlx::query(sql).bind(content_key).bind(content_url).bind(staging_key) | |
| 353 | + | } else { | |
| 354 | + | sqlx::query(sql).bind(content_key).bind(staging_key) | |
| 355 | + | }; | |
| 356 | + | affected += q.execute(&mut *conn).await?.rows_affected(); | |
| 357 | + | } | |
| 358 | + | Ok(affected) | |
| 359 | + | } | |
| 360 | + | ||
| 243 | 361 | /// Get items held for review, joined with creator info + latest scan layers. | |
| 244 | 362 | /// Oldest first. | |
| 245 | 363 | #[tracing::instrument(skip_all)] |
| @@ -284,7 +284,9 @@ | |||
| 284 | 284 | Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, | |
| 285 | 285 | Json(req): Json<UploadArtifactRequest>, | |
| 286 | 286 | ) -> Result<impl IntoResponse> { | |
| 287 | - | let app = verify_app_owner(&state, &sync_user, app_id).await?; | |
| 287 | + | // Ownership check (side effect); the app object itself is no longer needed to | |
| 288 | + | // build the key now that artifacts land at a random staging key. | |
| 289 | + | verify_app_owner(&state, &sync_user, app_id).await?; | |
| 288 | 290 | validate_target(&req.target)?; | |
| 289 | 291 | validate_arch(&req.arch)?; | |
| 290 | 292 | ||
| @@ -292,14 +294,16 @@ | |||
| 292 | 294 | return Err(AppError::BadRequest("file_size must be positive".to_string())); | |
| 293 | 295 | } | |
| 294 | 296 | ||
| 295 | - | // Verify the release belongs to this app (scoped lookup, not a full list scan) | |
| 296 | - | let release = db::ota::get_release(&state.db, app_id, release_id) | |
| 297 | + | // Verify the release belongs to this app (scoped lookup, not a full list scan). | |
| 298 | + | db::ota::get_release(&state.db, app_id, release_id) | |
| 297 | 299 | .await? | |
| 298 | 300 | .ok_or(AppError::NotFound)?; | |
| 299 | 301 | ||
| 300 | - | let s3_key = crate::storage::S3Client::generate_ota_artifact_key( | |
| 301 | - | app.id, &release.version, &req.target, &req.arch, | |
| 302 | - | ); | |
| 302 | + | // Staging key (unserved); the scan worker promotes it to the content key on a | |
| 303 | + | // Clean verdict (C1). The artifact ROW stays singleton per | |
| 304 | + | // (release, target, arch) — `create_artifact` below overwrites its `s3_key` | |
| 305 | + | // pointer on re-upload — so the object no longer needs a deterministic name. | |
| 306 | + | let s3_key = crate::storage::S3Client::generate_staging_key("artifact.bin"); | |
| 303 | 307 | ||
| 304 | 308 | let synckit_s3 = state.require_synckit_s3()?; | |
| 305 | 309 |
| @@ -256,6 +256,82 @@ | |||
| 256 | 256 | } | |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | + | /// Scan-then-promote closing move (C1): copy a Clean object from its unserved | |
| 260 | + | /// `staging/{uuid}` key to the immutable, content-addressed | |
| 261 | + | /// `{owner}/c/{sha256}.{ext}` key, repoint the entity (and, for CDN images, its | |
| 262 | + | /// materialized public URL) at the content key, mark it Clean, and enqueue the | |
| 263 | + | /// staging object for durable deletion. The bytes a buyer is served are then | |
| 264 | + | /// provably the bytes that were scanned: the content key is named by the scanned | |
| 265 | + | /// hash, the owner holds no presign to it, and the (owner-less, random) staging | |
| 266 | + | /// key they *can* re-PUT to is unserved and deleted. | |
| 267 | + | /// | |
| 268 | + | /// Shared by the scan worker's Clean path and the admin approve-held path so the | |
| 269 | + | /// two promote sites can't diverge. Ordering is fail-safe: the S3 copy runs | |
| 270 | + | /// first, then a single transaction repoints the row and enqueues the staging | |
| 271 | + | /// delete. If the copy or the DB write fails, the row keeps pointing at the | |
| 272 | + | /// still-present (unserved) staging key and the caller leaves the work un-done, | |
| 273 | + | /// so a retry re-promotes — the copy is idempotent (hash-named destination), and | |
| 274 | + | /// an already-promoted `{owner}/c/…` key short-circuits. | |
| 275 | + | #[allow(clippy::too_many_arguments)] | |
| 276 | + | pub async fn promote_staging_to_content( | |
| 277 | + | db: &sqlx::PgPool, | |
| 278 | + | backend: &dyn crate::storage::StorageBackend, | |
| 279 | + | cdn_base_url: Option<&str>, | |
| 280 | + | kind: crate::db::scan_jobs::ScanTargetKind, | |
| 281 | + | file_type: FileType, | |
| 282 | + | target_id: uuid::Uuid, | |
| 283 | + | owner: crate::db::UserId, | |
| 284 | + | staging_key: &str, | |
| 285 | + | sha256: &str, | |
| 286 | + | bucket: crate::storage::S3Bucket, | |
| 287 | + | ) -> crate::error::Result<()> { | |
| 288 | + | use crate::db::scan_jobs::ScanTargetKind; | |
| 289 | + | use crate::storage::{S3Client, S3Key}; | |
| 290 | + | ||
| 291 | + | if sha256.is_empty() { | |
| 292 | + | return Err(crate::error::AppError::Storage(format!( | |
| 293 | + | "cannot promote {staging_key}: scan recorded no content hash" | |
| 294 | + | ))); | |
| 295 | + | } | |
| 296 | + | // Already promoted (worker/admin retry, admin bulk over a mixed set): a | |
| 297 | + | // content key is `{owner}/c/…`, never `staging/…`. Nothing to copy or repoint. | |
| 298 | + | if !staging_key.starts_with("staging/") { | |
| 299 | + | return Ok(()); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | let ext = crate::storage::key_extension(staging_key); | |
| 303 | + | let content = S3Client::content_key(owner, sha256, ext); | |
| 304 | + | let content_str = content.as_str().to_string(); | |
| 305 | + | ||
| 306 | + | // 1. Promote the object in storage (copy staging -> content). Idempotent. | |
| 307 | + | backend | |
| 308 | + | .copy_object(&S3Key::from_stored(staging_key), &content) | |
| 309 | + | .await?; | |
| 310 | + | ||
| 311 | + | // 2. Repoint the row + enqueue the staging delete atomically. | |
| 312 | + | let mut tx = db.begin().await?; | |
| 313 | + | if kind.is_cdn_served_without_gate() { | |
| 314 | + | let content_url = if matches!(kind, ScanTargetKind::ContentInsertion) { | |
| 315 | + | // No materialized URL column (insertions are served presigned). | |
| 316 | + | String::new() | |
| 317 | + | } else { | |
| 318 | + | crate::storage::build_project_image_url(backend, cdn_base_url, &content_str).await? | |
| 319 | + | }; | |
| 320 | + | crate::db::scanning::promote_cdn_image_by_key(&mut tx, staging_key, &content_str, &content_url) | |
| 321 | + | .await?; | |
| 322 | + | } else { | |
| 323 | + | crate::db::scanning::promote_gated(&mut *tx, kind, file_type, target_id, &content_str).await?; | |
| 324 | + | } | |
| 325 | + | crate::db::pending_s3_deletions::enqueue_deletions( | |
| 326 | + | &mut *tx, | |
| 327 | + | &[(staging_key.to_string(), bucket.as_str().to_string())], | |
| 328 | + | "scan_promote_staging", | |
| 329 | + | ) | |
| 330 | + | .await?; | |
| 331 | + | tx.commit().await?; | |
| 332 | + | Ok(()) | |
| 333 | + | } | |
| 334 | + | ||
| 259 | 335 | /// Aggregate scan result across all layers | |
| 260 | 336 | #[derive(Debug, Clone)] | |
| 261 | 337 | pub struct ScanResult { |
| @@ -268,6 +268,14 @@ | |||
| 268 | 268 | return Err(e); | |
| 269 | 269 | } | |
| 270 | 270 | }; | |
| 271 | + | // Stamp the entity's terminal scan_status. For a Clean staging upload the | |
| 272 | + | // promote inside `run_pipeline_and_decide` already set the key column AND | |
| 273 | + | // `scan_status = 'clean'` in one transaction; this re-stamp is then a | |
| 274 | + | // harmless idempotent write. It is load-bearing, though, for a Clean file | |
| 275 | + | // that was uploaded server-side to a NON-staging key (the build runner's OTA | |
| 276 | + | // artifacts) — there is nothing to promote, so this is the only place its | |
| 277 | + | // status is cleared. (For the gate-less image kinds this is a no-op; their | |
| 278 | + | // row was stamped by the promote or the held-image branch above.) | |
| 271 | 279 | update_entity_status(&ctx.db, kind, target_id, entity_status).await?; | |
| 272 | 280 | ||
| 273 | 281 | // Verdict + duration metrics (Run 20 Observability): quarantine/hold/error | |
| @@ -564,7 +572,36 @@ | |||
| 564 | 572 | // upload's image stays hidden until an admin clears it — the same | |
| 565 | 573 | // fail-closed posture the gated Item/Version/Media kinds already get. | |
| 566 | 574 | // Quarantine never reaches here (it returned above with the row purged). | |
| 567 | - | if kind.is_cdn_served_without_gate() { | |
| 575 | + | // C1 scan-then-promote: a Clean verdict copies the object from its unserved | |
| 576 | + | // staging key to the immutable content key and repoints the entity (gated | |
| 577 | + | // kinds by id, CDN-image kinds by staging key, incl. rebuilding the public | |
| 578 | + | // URL) in one shared step. This is the ONLY place a served key comes into | |
| 579 | + | // existence — the presign handlers can only mint staging keys (the sealed | |
| 580 | + | // generators are `pub(crate)`, callable only from here via `content_key`). | |
| 581 | + | // Fail-safe: a copy/DB error bubbles up so `process_job` leaves the job | |
| 582 | + | // un-done and the entity keeps pointing at the (unserved) staging key; the | |
| 583 | + | // retry re-promotes. | |
| 584 | + | if status == FileScanStatus::Clean && job.s3_key.starts_with("staging/") { | |
| 585 | + | // Client-presigned upload: copy staging -> content key and repoint the | |
| 586 | + | // entity. A Clean file at a non-staging key was uploaded server-side to | |
| 587 | + | // its final key (build-runner OTA) and needs no promote — its status is | |
| 588 | + | // stamped by `update_entity_status` in `process_job`. | |
| 589 | + | super::promote_staging_to_content( | |
| 590 | + | &ctx.db, | |
| 591 | + | backend.as_ref(), | |
| 592 | + | ctx.cdn_base_url.as_deref(), | |
| 593 | + | kind, | |
| 594 | + | file_type, | |
| 595 | + | job.target_id, | |
| 596 | + | job.user_id, | |
| 597 | + | &job.s3_key, | |
| 598 | + | &result.sha256, | |
| 599 | + | bucket, | |
| 600 | + | ) | |
| 601 | + | .await?; | |
| 602 | + | } else if status != FileScanStatus::Clean && kind.is_cdn_served_without_gate() { | |
| 603 | + | // Held/pending image (never promoted): stamp the row keyed on its staging | |
| 604 | + | // key so the fail-closed render gate hides it until an admin clears it. | |
| 568 | 605 | match db::scanning::set_cdn_image_scan_status_by_key(&ctx.db, &job.s3_key, status).await { | |
| 569 | 606 | Ok(n) => tracing::info!( | |
| 570 | 607 | s3_key = %job.s3_key, target_kind = %kind.as_str(), scan_status = %status, rows = n, |
| @@ -12,6 +12,19 @@ | |||
| 12 | 12 | user_id.to_string() | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | /// Record a pending-upload row for a test-forged key. Under scan-then-promote the | |
| 16 | + | /// confirm proves ownership via `pending_uploads` (a `staging/{uuid}` key has no | |
| 17 | + | /// user in its path), so a test that fabricates a key + object must also register | |
| 18 | + | /// it as if presign had — otherwise the ownership gate correctly rejects it. | |
| 19 | + | async fn record_pending(h: &TestHarness, user_id: &str, s3_key: &str) { | |
| 20 | + | sqlx::query("INSERT INTO pending_uploads (user_id, s3_key, bucket) VALUES ($1::uuid, $2, 'main') ON CONFLICT DO NOTHING") | |
| 21 | + | .bind(user_id) | |
| 22 | + | .bind(s3_key) | |
| 23 | + | .execute(&h.db) | |
| 24 | + | .await | |
| 25 | + | .expect("record pending upload"); | |
| 26 | + | } | |
| 27 | + | ||
| 15 | 28 | #[tokio::test] | |
| 16 | 29 | async fn presign_requires_auth() { | |
| 17 | 30 | let mut h = TestHarness::with_storage().await; | |
| @@ -95,9 +108,11 @@ | |||
| 95 | 108 | let mut h = TestHarness::with_storage().await; | |
| 96 | 109 | let user_id = setup_creator(&mut h).await; | |
| 97 | 110 | ||
| 98 | - | // Pre-populate storage with a fake object (key must match user_id prefix) | |
| 111 | + | // Pre-populate storage with a fake object and record it as a pending upload | |
| 112 | + | // (the confirm now proves ownership via pending_uploads, not a key prefix). | |
| 99 | 113 | let s3_key = format!("{}/insertions/intro.mp3", user_id); | |
| 100 | 114 | h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); | |
| 115 | + | record_pending(&h, &user_id, &s3_key).await; | |
| 101 | 116 | ||
| 102 | 117 | let resp = h | |
| 103 | 118 | .client | |
| @@ -207,6 +222,7 @@ | |||
| 207 | 222 | async fn confirm_clip(h: &mut TestHarness, user_id: &str, file: &str, mime: &str) -> Value { | |
| 208 | 223 | let s3_key = format!("{}/insertions/{}", user_id, file); | |
| 209 | 224 | h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); | |
| 225 | + | record_pending(h, user_id, &s3_key).await; | |
| 210 | 226 | let resp = h | |
| 211 | 227 | .client | |
| 212 | 228 | .post_json( |
| @@ -260,7 +260,10 @@ | |||
| 260 | 260 | .unwrap(); | |
| 261 | 261 | assert_eq!(count, 1); | |
| 262 | 262 | assert_eq!(db_key, s3_key); | |
| 263 | - | assert!(s3_key.starts_with(&format!("projects/{}/gallery/", project_id))); | |
| 263 | + | // No scanner in this harness, so the row still holds the unserved staging key | |
| 264 | + | // (a Clean scan would later promote it to a content key). Presign hands out | |
| 265 | + | // `staging/{uuid}/{filename}`, never the old `projects/{id}/gallery/…` key. | |
| 266 | + | assert!(s3_key.starts_with("staging/"), "gallery presign must return a staging key: {s3_key}"); | |
| 264 | 267 | assert_eq!(storage_used(&h, &user_id).await, 500); | |
| 265 | 268 | } | |
| 266 | 269 |
| @@ -541,15 +541,24 @@ | |||
| 541 | 541 | .bind(&user_id).fetch_one(&h.db).await.unwrap(); | |
| 542 | 542 | assert_eq!(used_after_first, TINY_PNG.len() as i64); | |
| 543 | 543 | ||
| 544 | - | // Re-confirm the SAME key. Rejected as a duplicate ... | |
| 545 | - | let resp = h.client.post_json("/api/media/confirm", &confirm_body.to_string()).await; | |
| 544 | + | // A genuine duplicate under scan-then-promote: presign a SECOND upload of the | |
| 545 | + | // same folder+filename (a fresh staging key, so it passes the ownership gate), | |
| 546 | + | // then confirm. It collides on the (user, folder, filename) unique index and | |
| 547 | + | // is rejected as "already exists". | |
| 548 | + | let presign2 = json!({"file_name": "pic.png", "content_type": "image/png", "folder": ""}); | |
| 549 | + | let resp = h.client.post_json("/api/media/presign", &presign2.to_string()).await; | |
| 550 | + | assert!(resp.status.is_success(), "second presign failed: {}", resp.text); | |
| 551 | + | let s3_key2 = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string(); | |
| 552 | + | h.storage.as_ref().unwrap().put(&s3_key2, TINY_PNG.to_vec()); | |
| 553 | + | let confirm2 = json!({"s3_key": s3_key2, "file_name": "pic.png", "content_type": "image/png", "folder": ""}); | |
| 554 | + | let resp = h.client.post_json("/api/media/confirm", &confirm2.to_string()).await; | |
| 546 | 555 | assert!(resp.status.is_client_error(), "duplicate confirm should be rejected: {} {}", resp.status, resp.text); | |
| 547 | 556 | assert!(resp.text.contains("already exists"), "rejection should name the collision: {}", resp.text); | |
| 548 | 557 | ||
| 549 | - | // ... but the live object the committed row points at must SURVIVE (the HIGH). | |
| 558 | + | // ... but the FIRST upload's live object must SURVIVE (the HIGH). | |
| 550 | 559 | assert!( | |
| 551 | 560 | h.storage.as_ref().unwrap().object_exists(&s3_key).await.unwrap(), | |
| 552 | - | "duplicate confirm must NOT delete the live object" | |
| 561 | + | "duplicate confirm must NOT delete the first upload's live object" | |
| 553 | 562 | ); | |
| 554 | 563 | // ... and the rolled-back tx must not have double-charged storage. | |
| 555 | 564 | let used_after_second: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid") |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | use serde_json::{json, Value}; | |
| 5 | 5 | ||
| 6 | 6 | use makenotwork::db::UserId; | |
| 7 | + | use makenotwork::storage::StorageBackend; | |
| 7 | 8 | ||
| 8 | 9 | /// Helper: set up a trusted creator with a project and audio item. | |
| 9 | 10 | async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String) { | |
| @@ -49,7 +50,9 @@ | |||
| 49 | 50 | // completion before asserting final state. | |
| 50 | 51 | h.drain_scan_jobs().await; | |
| 51 | 52 | ||
| 52 | - | // Verify audio_s3_key was set in DB | |
| 53 | + | // C1 scan-then-promote: a Clean scan copies the object from its staging key | |
| 54 | + | // to a content-addressed served key and repoints the row. audio_s3_key must | |
| 55 | + | // NO LONGER be the staging key — it is now `{user}/c/{sha256}.mp3`. | |
| 53 | 56 | let db_key: Option<String> = sqlx::query_scalar( | |
| 54 | 57 | "SELECT audio_s3_key FROM items WHERE id = $1::uuid", | |
| 55 | 58 | ) | |
| @@ -57,7 +60,16 @@ | |||
| 57 | 60 | .fetch_one(&h.db) | |
| 58 | 61 | .await | |
| 59 | 62 | .unwrap(); | |
| 60 | - | assert_eq!(db_key.as_deref(), Some(s3_key.as_str())); | |
| 63 | + | let db_key = db_key.expect("audio_s3_key set"); | |
| 64 | + | assert_ne!(db_key, s3_key, "clean scan must promote off the staging key"); | |
| 65 | + | assert!(!db_key.starts_with("staging/"), "promoted key must not be a staging key: {db_key}"); | |
| 66 | + | assert!(db_key.contains("/c/"), "promoted key must be content-addressed: {db_key}"); | |
| 67 | + | assert!(db_key.ends_with(".mp3"), "content key keeps the extension: {db_key}"); | |
| 68 | + | ||
| 69 | + | // The served object exists at the content key; the staging object is gone | |
| 70 | + | // (enqueued for durable deletion after the promote copy). | |
| 71 | + | let store = h.storage.as_ref().unwrap(); | |
| 72 | + | assert!(store.object_exists(&db_key).await.unwrap(), "content object must exist after promote"); | |
| 61 | 73 | ||
| 62 | 74 | // Verify scan_status is clean | |
| 63 | 75 | let scan_status: String = sqlx::query_scalar( | |
| @@ -70,6 +82,59 @@ | |||
| 70 | 82 | assert_eq!(scan_status, "clean"); | |
| 71 | 83 | } | |
| 72 | 84 | ||
| 85 | + | /// The C1 invariant end-to-end: after a Clean scan promotes an upload to its | |
| 86 | + | /// content key, a creator re-PUT to the (still-known) staging URL cannot change | |
| 87 | + | /// the bytes a buyer is served. The served key is the content key; the staging | |
| 88 | + | /// object is a dead end. | |
| 89 | + | #[tokio::test] | |
| 90 | + | async fn repost_to_staging_after_clean_cannot_change_served_bytes() { | |
| 91 | + | let mut h = TestHarness::with_storage_and_scanner().await; | |
| 92 | + | let (_project_id, item_id) = setup_creator_with_item(&mut h).await; | |
| 93 | + | ||
| 94 | + | // Presign → the client only ever holds a presign to the staging key. | |
| 95 | + | let body = json!({"item_id": item_id, "file_type": "audio", "file_name": "song.mp3", "content_type": "audio/mpeg"}); | |
| 96 | + | let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await; | |
| 97 | + | assert!(resp.status.is_success(), "presign: {}", resp.text); | |
| 98 | + | let staging_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string(); | |
| 99 | + | assert!(staging_key.starts_with("staging/")); | |
| 100 | + | ||
| 101 | + | // Upload clean bytes and confirm. | |
| 102 | + | let mut clean = b"ID3".to_vec(); | |
| 103 | + | clean.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); | |
| 104 | + | clean.extend_from_slice(&[0xAAu8; 200]); | |
| 105 | + | h.storage.as_ref().unwrap().put(&staging_key, clean.clone()); | |
| 106 | + | let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": staging_key}); | |
| 107 | + | let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await; | |
| 108 | + | assert!(resp.status.is_success(), "confirm: {}", resp.text); | |
| 109 | + | h.drain_scan_jobs().await; | |
| 110 | + | ||
| 111 | + | // The row now serves a content key, NOT the staging key. | |
| 112 | + | let served_key: String = sqlx::query_scalar::<_, Option<String>>("SELECT audio_s3_key FROM items WHERE id = $1::uuid") | |
| 113 | + | .bind(&item_id) | |
| 114 | + | .fetch_one(&h.db) | |
| 115 | + | .await | |
| 116 | + | .unwrap() | |
| 117 | + | .expect("audio promoted"); | |
| 118 | + | assert!(served_key.contains("/c/"), "served key must be content-addressed: {served_key}"); | |
| 119 | + | assert_ne!(served_key, staging_key); | |
| 120 | + | ||
| 121 | + | let store = h.storage.as_ref().unwrap(); | |
| 122 | + | let served_before = store.download_object(&served_key).await.unwrap(); | |
| 123 | + | assert_eq!(served_before, clean, "the content object holds the scanned bytes"); | |
| 124 | + | ||
| 125 | + | // The attack: re-PUT malware to the staging key the creator still holds a | |
| 126 | + | // presign for. This is the exact move the old mutable-served-key design let | |
| 127 | + | // a creator use to swap post-scan bytes. | |
| 128 | + | let malware = vec![0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00]; | |
| 129 | + | store.put(&staging_key, malware.clone()); | |
| 130 | + | ||
| 131 | + | // The served key is untouched: a buyer still gets the scanned bytes. The | |
| 132 | + | // staging object is irrelevant — it is not what the row serves. | |
| 133 | + | let served_after = store.download_object(&served_key).await.unwrap(); | |
| 134 | + | assert_eq!(served_after, clean, "re-PUT to the staging key must NOT change the served bytes"); | |
| 135 | + | assert_ne!(served_after, malware); | |
| 136 | + | } | |
| 137 | + | ||
| 73 | 138 | #[tokio::test] | |
| 74 | 139 | async fn confirm_upload_bad_magic_quarantined() { | |
| 75 | 140 | let mut h = TestHarness::with_storage_and_scanner().await; | |
| @@ -167,7 +232,12 @@ | |||
| 167 | 232 | .await | |
| 168 | 233 | .expect("the item row must still exist — quarantining a cover must not delete the track"); | |
| 169 | 234 | assert_eq!(status, "clean", "a quarantined cover must not touch the track's gate status"); | |
| 170 | - | assert_eq!(audio.as_deref(), Some(audio_key.as_str()), "the audio track must be preserved"); | |
| 235 | + | // The audio track was clean-scanned, so it was promoted off its staging key to | |
| 236 | + | // a content-addressed key — the point is it survives the cover quarantine | |
| 237 | + | // (non-null, promoted, still gated Clean), not that it keeps the staging name. | |
| 238 | + | let audio = audio.expect("the audio track must be preserved"); | |
| 239 | + | assert_ne!(audio, audio_key, "the audio track must have been promoted, not delisted"); | |
| 240 | + | assert!(audio.contains("/c/"), "the surviving track must be content-addressed: {audio}"); | |
| 171 | 241 | assert_eq!(ck, None, "the quarantined cover key must be NULLed"); | |
| 172 | 242 | assert_eq!(cu, None, "the quarantined cover URL must be NULLed so it stops rendering"); | |
| 173 | 243 | } |