max / makenotwork
10 files changed,
+361 insertions,
-221 deletions
| @@ -152,6 +152,13 @@ fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&s | |||
| 152 | 152 | } | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | + | /// A successfully built target: `(target_os, arch, s3_key, signature)`. | |
| 156 | + | type TargetArtifact = (String, String, String, String); | |
| 157 | + | /// A failed target: `(target_str, error)`. | |
| 158 | + | type TargetError = (String, String); | |
| 159 | + | /// One host group's results: its artifacts and its per-target failures. | |
| 160 | + | type GroupOutput = (Vec<TargetArtifact>, Vec<TargetError>); | |
| 161 | + | ||
| 155 | 162 | /// Execute a full build: iterate targets, SSH to hosts, build, upload artifacts. | |
| 156 | 163 | #[tracing::instrument(skip_all, name = "build_runner::run_build", fields(build_id = %build.id, version = %build.version))] | |
| 157 | 164 | async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |
| @@ -159,6 +166,12 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 159 | 166 | let mut failed_count: usize = 0; | |
| 160 | 167 | let mut first_error: Option<String> = None; | |
| 161 | 168 | ||
| 169 | + | // Resolve each target to its build host up front. Synchronous failures (bad | |
| 170 | + | // target format, no host configured) are tallied here; resolvable targets are | |
| 171 | + | // grouped by host so independent hosts (e.g. linux vs darwin) build | |
| 172 | + | // concurrently while same-host targets stay serial — a multi-target release no | |
| 173 | + | // longer serializes end to end at up to 30 min/target (Perf-S2, Run 9). | |
| 174 | + | let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); // host -> [(os, arch)] | |
| 162 | 175 | for target_str in &config.targets { | |
| 163 | 176 | let Some((target_os, arch)): Option<(&str, &str)> = target_str.split_once('/') else { | |
| 164 | 177 | let msg = format!("invalid target format: {target_str}\n"); | |
| @@ -184,22 +197,66 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 184 | 197 | } | |
| 185 | 198 | }; | |
| 186 | 199 | ||
| 187 | - | match execute_target(state, build, config, host, target_os, arch).await { | |
| 188 | - | Ok((s3_key, signature)) => { | |
| 189 | - | artifact_keys.push((target_os.to_string(), arch.to_string(), s3_key, signature)); | |
| 200 | + | let entry = (target_os.to_string(), arch.to_string()); | |
| 201 | + | match groups.iter_mut().find(|(h, _)| h == host) { | |
| 202 | + | Some((_, targets)) => targets.push(entry), | |
| 203 | + | None => groups.push((host.to_string(), vec![entry])), | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // One task per host; targets within a host run serially. Results are gathered | |
| 208 | + | // by group index so the merged artifact order stays deterministic regardless | |
| 209 | + | // of which host finishes first. | |
| 210 | + | let group_count = groups.len(); | |
| 211 | + | let mut set: tokio::task::JoinSet<(usize, GroupOutput)> = tokio::task::JoinSet::new(); | |
| 212 | + | for (idx, (host, targets)) in groups.into_iter().enumerate() { | |
| 213 | + | let state = state.clone(); | |
| 214 | + | let build = build.clone(); | |
| 215 | + | let config = config.clone(); | |
| 216 | + | set.spawn(async move { | |
| 217 | + | let mut oks: Vec<TargetArtifact> = Vec::new(); | |
| 218 | + | let mut errs: Vec<TargetError> = Vec::new(); | |
| 219 | + | for (target_os, arch) in &targets { | |
| 220 | + | match execute_target(&state, &build, &config, &host, target_os, arch).await { | |
| 221 | + | Ok((s3_key, signature)) => { | |
| 222 | + | oks.push((target_os.clone(), arch.clone(), s3_key, signature)); | |
| 223 | + | } | |
| 224 | + | Err(e) => errs.push((format!("{target_os}/{arch}"), e)), | |
| 225 | + | } | |
| 190 | 226 | } | |
| 227 | + | (idx, (oks, errs)) | |
| 228 | + | }); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | let mut gathered: Vec<Option<GroupOutput>> = (0..group_count).map(|_| None).collect(); | |
| 232 | + | while let Some(res) = set.join_next().await { | |
| 233 | + | match res { | |
| 234 | + | Ok((idx, out)) => gathered[idx] = Some(out), | |
| 191 | 235 | Err(e) => { | |
| 192 | - | let msg = format!("target {target_str} failed: {e}\n"); | |
| 193 | - | tracing::error!("{}", msg.trim()); | |
| 194 | - | let _ = append_log_bounded(state, build.id, &msg).await; | |
| 236 | + | // A host group's task panicked; the build can't be considered | |
| 237 | + | // complete, so fail it rather than silently dropping its targets. | |
| 238 | + | tracing::error!(error = ?e, "build host group task panicked"); | |
| 195 | 239 | failed_count += 1; | |
| 196 | 240 | if first_error.is_none() { | |
| 197 | - | first_error = Some(e); | |
| 241 | + | first_error = Some("a build host group task panicked".to_string()); | |
| 198 | 242 | } | |
| 199 | 243 | } | |
| 200 | 244 | } | |
| 201 | 245 | } | |
| 202 | 246 | ||
| 247 | + | for (oks, errs) in gathered.into_iter().flatten() { | |
| 248 | + | artifact_keys.extend(oks); | |
| 249 | + | for (target_str, e) in errs { | |
| 250 | + | let msg = format!("target {target_str} failed: {e}\n"); | |
| 251 | + | tracing::error!("{}", msg.trim()); | |
| 252 | + | let _ = append_log_bounded(state, build.id, &msg).await; | |
| 253 | + | failed_count += 1; | |
| 254 | + | if first_error.is_none() { | |
| 255 | + | first_error = Some(e); | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 203 | 260 | if artifact_keys.is_empty() || failed_count > 0 { | |
| 204 | 261 | let err_msg = build_failure_message(artifact_keys.len(), failed_count, first_error.as_deref()); | |
| 205 | 262 | if let Err(e) = db::builds::update_build_status( |
| @@ -474,13 +474,18 @@ pub async fn get_expired_grace_creators(pool: &PgPool) -> Result<Vec<UserId>> { | |||
| 474 | 474 | Ok(ids) | |
| 475 | 475 | } | |
| 476 | 476 | ||
| 477 | - | /// Mark a creator's post-grace enforcement as applied. | |
| 477 | + | /// Stamp `grace_enforced_at` for all given creators in one statement, paired with | |
| 478 | + | /// `items::hide_all_items_for_users` on the post-grace sweep (Perf-S4, Run 9). | |
| 479 | + | /// No-op on an empty slice. | |
| 478 | 480 | #[tracing::instrument(skip_all)] | |
| 479 | - | pub async fn mark_grace_enforced(pool: &PgPool, user_id: UserId) -> Result<()> { | |
| 481 | + | pub async fn mark_grace_enforced_batch(pool: &PgPool, user_ids: &[UserId]) -> Result<()> { | |
| 482 | + | if user_ids.is_empty() { | |
| 483 | + | return Ok(()); | |
| 484 | + | } | |
| 480 | 485 | sqlx::query( | |
| 481 | - | "UPDATE creator_subscriptions SET grace_enforced_at = NOW() WHERE user_id = $1", | |
| 486 | + | "UPDATE creator_subscriptions SET grace_enforced_at = NOW() WHERE user_id = ANY($1)", | |
| 482 | 487 | ) | |
| 483 | - | .bind(user_id) | |
| 488 | + | .bind(user_ids) | |
| 484 | 489 | .execute(pool) | |
| 485 | 490 | .await?; | |
| 486 | 491 |
| @@ -1026,18 +1026,22 @@ pub async fn get_item_by_project_and_slug( | |||
| 1026 | 1026 | Ok(item) | |
| 1027 | 1027 | } | |
| 1028 | 1028 | ||
| 1029 | - | /// Hide all items for a user (set is_public = false). Used for post-grace enforcement. | |
| 1030 | - | /// Returns the number of items hidden. | |
| 1029 | + | /// Hide every public item for ALL given creators in one statement, for the | |
| 1030 | + | /// post-grace scheduler sweep — replaces N per-user UPDATEs on the lock-held tick | |
| 1031 | + | /// (Perf-S4, Run 9). Returns the total rows affected. No-op on an empty slice. | |
| 1031 | 1032 | #[tracing::instrument(skip_all)] | |
| 1032 | - | pub async fn hide_all_items_for_user(pool: &PgPool, user_id: UserId) -> Result<u64> { | |
| 1033 | + | pub async fn hide_all_items_for_users(pool: &PgPool, user_ids: &[UserId]) -> Result<u64> { | |
| 1034 | + | if user_ids.is_empty() { | |
| 1035 | + | return Ok(0); | |
| 1036 | + | } | |
| 1033 | 1037 | let result = sqlx::query( | |
| 1034 | 1038 | r#" | |
| 1035 | 1039 | UPDATE items SET is_public = false | |
| 1036 | - | WHERE project_id IN (SELECT id FROM projects WHERE user_id = $1) | |
| 1040 | + | WHERE project_id IN (SELECT id FROM projects WHERE user_id = ANY($1)) | |
| 1037 | 1041 | AND is_public = true | |
| 1038 | 1042 | "#, | |
| 1039 | 1043 | ) | |
| 1040 | - | .bind(user_id) | |
| 1044 | + | .bind(user_ids) | |
| 1041 | 1045 | .execute(pool) | |
| 1042 | 1046 | .await?; | |
| 1043 | 1047 |
| @@ -510,6 +510,55 @@ pub async fn claim_free_item<'e>( | |||
| 510 | 510 | Ok(result.rows_affected() > 0) | |
| 511 | 511 | } | |
| 512 | 512 | ||
| 513 | + | /// Batch variant of [`claim_free_item`] for bundle grants: claims every child | |
| 514 | + | /// item for one buyer in a single INSERT instead of N round-trips (each with its | |
| 515 | + | /// own pool acquire) on the Stripe webhook / checkout hot path (Perf-S4, Run 9). | |
| 516 | + | /// Each row is idempotent via the same partial-unique ON CONFLICT as the single | |
| 517 | + | /// claim, and child items deliberately do not increment `sales_count`. Returns the | |
| 518 | + | /// number of rows actually inserted. No-op on an empty slice. | |
| 519 | + | #[tracing::instrument(skip_all)] | |
| 520 | + | pub async fn claim_free_items_batch<'e>( | |
| 521 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 522 | + | buyer_id: UserId, | |
| 523 | + | seller_id: UserId, | |
| 524 | + | seller_username: &str, | |
| 525 | + | parent_transaction_id: Option<TransactionId>, | |
| 526 | + | items: &[(ItemId, &str)], | |
| 527 | + | ) -> Result<u64> { | |
| 528 | + | if items.is_empty() { | |
| 529 | + | return Ok(0); | |
| 530 | + | } | |
| 531 | + | let item_ids: Vec<ItemId> = items.iter().map(|(id, _)| *id).collect(); | |
| 532 | + | let item_titles: Vec<&str> = items.iter().map(|(_, title)| *title).collect(); | |
| 533 | + | ||
| 534 | + | // The per-row claim id mirrors the single claim's `free-claim-{buyer}-{item}` | |
| 535 | + | // so a later single claim of the same item still collides idempotently. | |
| 536 | + | let result = sqlx::query( | |
| 537 | + | r#" | |
| 538 | + | INSERT INTO transactions | |
| 539 | + | (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, | |
| 540 | + | stripe_checkout_session_id, status, completed_at, item_title, | |
| 541 | + | seller_username, share_contact, parent_transaction_id) | |
| 542 | + | SELECT | |
| 543 | + | $1, $2, t.item_id, 0, 0, | |
| 544 | + | 'free-claim-' || $1::text || '-' || t.item_id::text, | |
| 545 | + | 'completed', NOW(), t.item_title, $3, false, $4 | |
| 546 | + | FROM UNNEST($5::uuid[], $6::text[]) AS t(item_id, item_title) | |
| 547 | + | ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING | |
| 548 | + | "#, | |
| 549 | + | ) | |
| 550 | + | .bind(buyer_id) | |
| 551 | + | .bind(seller_id) | |
| 552 | + | .bind(seller_username) | |
| 553 | + | .bind(parent_transaction_id) | |
| 554 | + | .bind(&item_ids) | |
| 555 | + | .bind(&item_titles) | |
| 556 | + | .execute(executor) | |
| 557 | + | .await?; | |
| 558 | + | ||
| 559 | + | Ok(result.rows_affected()) | |
| 560 | + | } | |
| 561 | + | ||
| 513 | 562 | /// Optional parameters for generating a license key inside a claim transaction. | |
| 514 | 563 | pub struct LicenseKeyParams<'a> { | |
| 515 | 564 | pub key_code: &'a KeyCode, |
| @@ -105,39 +105,55 @@ async fn admin_mt_provision( | |||
| 105 | 105 | let owner_map: std::collections::HashMap<db::UserId, &db::DbUser> = | |
| 106 | 106 | owners.iter().map(|u| (u.id, u)).collect(); | |
| 107 | 107 | ||
| 108 | + | // Fan the per-project MT HTTP call (5s timeout each) out over a bounded | |
| 109 | + | // JoinSet instead of awaiting them strictly serially — provisioning latency | |
| 110 | + | // was MT-latency x project-count over the whole table (Perf-S3, Run 9). Cap | |
| 111 | + | // concurrency so a large backfill can't open hundreds of MT connections at | |
| 112 | + | // once; mirror admin_shutdown_notice's bound. | |
| 113 | + | let parallelism = crate::constants::BROADCAST_PARALLELISM; | |
| 114 | + | let mut set: tokio::task::JoinSet<bool> = tokio::task::JoinSet::new(); | |
| 115 | + | ||
| 108 | 116 | for project in &projects { | |
| 109 | 117 | let Some(user) = owner_map.get(&project.user_id) else { | |
| 110 | 118 | failed += 1; | |
| 111 | 119 | continue; | |
| 112 | 120 | }; | |
| 113 | 121 | ||
| 114 | - | match mt | |
| 115 | - | .create_community(&crate::mt_client::CreateCommunityRequest { | |
| 116 | - | name: project.title.clone(), | |
| 117 | - | slug: project.slug.to_string(), | |
| 118 | - | description: project.description.clone(), | |
| 119 | - | owner_mnw_id: *user.id, | |
| 120 | - | owner_username: user.username.to_string(), | |
| 121 | - | owner_display_name: user.display_name.clone(), | |
| 122 | - | }) | |
| 123 | - | .await | |
| 124 | - | { | |
| 125 | - | Ok(resp) => { | |
| 126 | - | if let Err(e) = | |
| 127 | - | db::projects::set_mt_community_id(&state.db, project.id, resp.community_id) | |
| 128 | - | .await | |
| 129 | - | { | |
| 130 | - | tracing::error!(error = ?e, project_id = %project.id, "failed to store MT community ID"); | |
| 131 | - | failed += 1; | |
| 132 | - | } else { | |
| 133 | - | provisioned += 1; | |
| 122 | + | if set.len() >= parallelism && let Some(res) = set.join_next().await { | |
| 123 | + | if res.unwrap_or(false) { provisioned += 1 } else { failed += 1 } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | let mt = mt.clone(); | |
| 127 | + | let pool = state.db.clone(); | |
| 128 | + | let req = crate::mt_client::CreateCommunityRequest { | |
| 129 | + | name: project.title.clone(), | |
| 130 | + | slug: project.slug.to_string(), | |
| 131 | + | description: project.description.clone(), | |
| 132 | + | owner_mnw_id: *user.id, | |
| 133 | + | owner_username: user.username.to_string(), | |
| 134 | + | owner_display_name: user.display_name.clone(), | |
| 135 | + | }; | |
| 136 | + | let project_id = project.id; | |
| 137 | + | let slug = project.slug.to_string(); | |
| 138 | + | set.spawn(async move { | |
| 139 | + | match mt.create_community(&req).await { | |
| 140 | + | Ok(resp) => match db::projects::set_mt_community_id(&pool, project_id, resp.community_id).await { | |
| 141 | + | Ok(()) => true, | |
| 142 | + | Err(e) => { | |
| 143 | + | tracing::error!(error = ?e, project_id = %project_id, "failed to store MT community ID"); | |
| 144 | + | false | |
| 145 | + | } | |
| 146 | + | }, | |
| 147 | + | Err(e) => { | |
| 148 | + | tracing::error!(error = ?e, slug = %slug, "MT community provisioning failed"); | |
| 149 | + | false | |
| 134 | 150 | } | |
| 135 | 151 | } | |
| 136 | - | Err(e) => { | |
| 137 | - | tracing::error!(error = ?e, slug = %project.slug, "MT community provisioning failed"); | |
| 138 | - | failed += 1; | |
| 139 | - | } | |
| 140 | - | } | |
| 152 | + | }); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | while let Some(res) = set.join_next().await { | |
| 156 | + | if res.unwrap_or(false) { provisioned += 1 } else { failed += 1 } | |
| 141 | 157 | } | |
| 142 | 158 | ||
| 143 | 159 | tracing::info!(total, provisioned, failed, "MT community backfill complete"); |
| @@ -396,29 +396,29 @@ pub(crate) async fn grant_bundle_items( | |||
| 396 | 396 | _ => return, | |
| 397 | 397 | }; | |
| 398 | 398 | ||
| 399 | - | for child in &child_items { | |
| 400 | - | let claim = db::transactions::ClaimParams { | |
| 401 | - | buyer_id, | |
| 402 | - | item_id: child.id, | |
| 403 | - | seller_id, | |
| 404 | - | item_title: &child.title, | |
| 405 | - | seller_username: &seller.username, | |
| 406 | - | share_contact: false, | |
| 407 | - | parent_transaction_id, | |
| 408 | - | }; | |
| 409 | - | // Idempotent: ON CONFLICT DO NOTHING if already claimed | |
| 410 | - | if let Err(e) = db::transactions::claim_free_item(&state.db, &claim).await { | |
| 411 | - | tracing::warn!( | |
| 412 | - | child_item_id = %child.id, bundle_id = %bundle_id, | |
| 413 | - | error = ?e, "failed to grant bundle child item" | |
| 414 | - | ); | |
| 415 | - | } | |
| 416 | - | // Deliberately NOT incrementing sales_count for child items | |
| 399 | + | // Claim all children in one INSERT instead of N per-child round-trips (each | |
| 400 | + | // re-acquiring a pool connection) on the webhook hot path (Perf-S4, Run 9). | |
| 401 | + | // Idempotent via ON CONFLICT; child items deliberately do not bump sales_count. | |
| 402 | + | let items: Vec<(db::ItemId, &str)> = | |
| 403 | + | child_items.iter().map(|c| (c.id, c.title.as_str())).collect(); | |
| 404 | + | match db::transactions::claim_free_items_batch( | |
| 405 | + | &state.db, | |
| 406 | + | buyer_id, | |
| 407 | + | seller_id, | |
| 408 | + | &seller.username, | |
| 409 | + | parent_transaction_id, | |
| 410 | + | &items, | |
| 411 | + | ) | |
| 412 | + | .await | |
| 413 | + | { | |
| 414 | + | Ok(granted) => tracing::info!( | |
| 415 | + | bundle_id = %bundle_id, buyer_id = %buyer_id, | |
| 416 | + | child_count = child_items.len(), granted, | |
| 417 | + | "granted bundle child items" | |
| 418 | + | ), | |
| 419 | + | Err(e) => tracing::warn!( | |
| 420 | + | bundle_id = %bundle_id, error = ?e, | |
| 421 | + | "failed to grant bundle child items" | |
| 422 | + | ), | |
| 417 | 423 | } | |
| 418 | - | ||
| 419 | - | tracing::info!( | |
| 420 | - | bundle_id = %bundle_id, buyer_id = %buyer_id, | |
| 421 | - | child_count = child_items.len(), | |
| 422 | - | "granted bundle child items" | |
| 423 | - | ); | |
| 424 | 424 | } |
| @@ -238,6 +238,65 @@ pub struct ScanPipeline { | |||
| 238 | 238 | metadefender_api_key: Option<String>, | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | + | /// How the bytes to scan are sourced. This is the ONLY thing that differs between | |
| 242 | + | /// the buffered and streaming scan paths — all scan POLICY (CPU layers off the | |
| 243 | + | /// runtime, ClamAV, URLhaus host extraction, external lookups, byte caps) runs in | |
| 244 | + | /// the single private [`ScanPipeline::run_scan`], so a policy cannot drift between | |
| 245 | + | /// the two entry points (ultra-fuzz CHRONIC: scan()/scan_stream() twin divergence, | |
| 246 | + | /// closed Run 9). | |
| 247 | + | enum ScanInput { | |
| 248 | + | /// Whole object held in memory — small uploads. | |
| 249 | + | Buffered(bytes::Bytes), | |
| 250 | + | /// Object spooled to a tempfile and scanned via a memory map. The retained | |
| 251 | + | /// `SpoolHandle` keeps the file on disk (and unlinks it on drop) so ClamAV can | |
| 252 | + | /// stream it by path while the CPU layers read the map. | |
| 253 | + | Spooled { | |
| 254 | + | map: std::sync::Arc<memmap2::Mmap>, | |
| 255 | + | spool: spool::SpoolHandle, | |
| 256 | + | }, | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// A cheaply-cloneable, `Send + 'static` view of the scan bytes for the | |
| 260 | + | /// `spawn_blocking` CPU work (a `Bytes` refcount bump or an `Arc<Mmap>` clone). | |
| 261 | + | #[derive(Clone)] | |
| 262 | + | enum ScanBytes { | |
| 263 | + | Buffered(bytes::Bytes), | |
| 264 | + | Mapped(std::sync::Arc<memmap2::Mmap>), | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | impl ScanBytes { | |
| 268 | + | fn as_slice(&self) -> &[u8] { | |
| 269 | + | match self { | |
| 270 | + | ScanBytes::Buffered(b) => b, | |
| 271 | + | ScanBytes::Mapped(m) => &m[..], | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /// Where ClamAV reads its bytes: the in-memory buffer, or the spool path it | |
| 277 | + | /// streams via INSTREAM frames. | |
| 278 | + | enum ClamavSource { | |
| 279 | + | Buffered(bytes::Bytes), | |
| 280 | + | Path(std::path::PathBuf), | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | impl ScanInput { | |
| 284 | + | /// A `Send + 'static` byte handle for `spawn_blocking` CPU work. | |
| 285 | + | fn byte_handle(&self) -> ScanBytes { | |
| 286 | + | match self { | |
| 287 | + | ScanInput::Buffered(b) => ScanBytes::Buffered(b.clone()), | |
| 288 | + | ScanInput::Spooled { map, .. } => ScanBytes::Mapped(std::sync::Arc::clone(map)), | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | fn len(&self) -> usize { | |
| 293 | + | match self { | |
| 294 | + | ScanInput::Buffered(b) => b.len(), | |
| 295 | + | ScanInput::Spooled { map, .. } => map.len(), | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 241 | 300 | impl ScanPipeline { | |
| 242 | 301 | /// Create a new pipeline, compiling YARA rules from the configured directory. | |
| 243 | 302 | pub fn new(config: &ScanConfig) -> Result<Self, String> { | |
| @@ -312,111 +371,32 @@ impl ScanPipeline { | |||
| 312 | 371 | Ok(()) | |
| 313 | 372 | } | |
| 314 | 373 | ||
| 315 | - | /// Run all applicable scanning layers against file data. | |
| 316 | - | /// | |
| 317 | - | /// CPU-bound layers (sha256, content-type, structural, archive, yara) run | |
| 318 | - | /// on a blocking-pool thread via `spawn_blocking` so they don't stall the | |
| 319 | - | /// tokio runtime — the 4 in-process layers can each take seconds on a | |
| 320 | - | /// 100 MB file. Network-bound layers (ClamAV, MalwareBazaar) run | |
| 321 | - | /// concurrently with the sync block via `tokio::join!`. | |
| 322 | - | pub async fn scan( | |
| 374 | + | /// Buffered scan entry point — small uploads held in memory. A thin adapter | |
| 375 | + | /// over [`run_scan`](Self::run_scan); all scan policy lives there. | |
| 376 | + | pub(crate) async fn scan( | |
| 323 | 377 | self: std::sync::Arc<Self>, | |
| 324 | 378 | data: impl Into<bytes::Bytes>, | |
| 325 | 379 | file_type: FileType, | |
| 326 | 380 | ) -> ScanResult { | |
| 327 | 381 | // `bytes::Bytes` is already a cheaply-cloneable refcounted buffer, so the | |
| 328 | 382 | // download hands its single aggregated allocation straight here with no | |
| 329 | - | // extra copy — the buffered scan path previously aggregated then `to_vec`'d | |
| 330 | - | // the body, transiently doubling to ~200 MB for a 100 MB file (Run #2 | |
| 331 | - | // Performance SERIOUS). Each consumer below clones the `Bytes` handle (a | |
| 332 | - | // refcount bump) and reads it as `&[u8]` by deref coercion. | |
| 333 | - | let data: bytes::Bytes = data.into(); | |
| 334 | - | let file_size = data.len() as u64; | |
| 335 | - | ||
| 336 | - | // Sync layers + hash, off the runtime | |
| 337 | - | let sync_data = data.clone(); | |
| 338 | - | let sync_self = std::sync::Arc::clone(&self); | |
| 339 | - | let sync_fut = | |
| 340 | - | tokio::task::spawn_blocking(move || sync_self.run_sync_layers(&sync_data, file_type)); | |
| 341 | - | ||
| 342 | - | // Async layers — ClamAV needs the bytes, MalwareBazaar needs only the hash | |
| 343 | - | // but the hash is computed in the sync block, so we run MalwareBazaar | |
| 344 | - | // after the sync block returns (its endpoint is fast). ClamAV can run | |
| 345 | - | // concurrently with the sync block. | |
| 346 | - | let clamav_data = data.clone(); | |
| 347 | - | let clamav_socket = self.clamav_socket.clone(); | |
| 348 | - | let clamav_fut = async move { | |
| 349 | - | match clamav_socket { | |
| 350 | - | Some(socket) => clamav::scan_with_clamav(&socket, &clamav_data).await, | |
| 351 | - | None => LayerResult { | |
| 352 | - | layer: "clamav", | |
| 353 | - | verdict: LayerVerdict::Skip, | |
| 354 | - | detail: Some("ClamAV not configured".to_string()), | |
| 355 | - | }, | |
| 356 | - | } | |
| 357 | - | }; | |
| 358 | - | ||
| 359 | - | // Layer 7: URLhaus — extract URLs from the bytes, query hosts. | |
| 360 | - | // Runs concurrently with the sync block (independent of the hash). | |
| 361 | - | let urlhaus_data = data.clone(); | |
| 362 | - | let urlhaus_enabled = self.urlhaus_enabled; | |
| 363 | - | let urlhaus_key = self.abuse_ch_auth_key.clone(); | |
| 364 | - | let urlhaus_fut = async move { | |
| 365 | - | if urlhaus_enabled { | |
| 366 | - | urlhaus::check_urlhaus(&urlhaus_data, urlhaus_key.as_deref()).await | |
| 367 | - | } else { | |
| 368 | - | LayerResult { | |
| 369 | - | layer: "urlhaus", | |
| 370 | - | verdict: LayerVerdict::Skip, | |
| 371 | - | detail: Some("URLhaus lookups disabled".to_string()), | |
| 372 | - | } | |
| 373 | - | } | |
| 374 | - | }; | |
| 375 | - | ||
| 376 | - | let (sync_result, clamav_result, urlhaus_result) = | |
| 377 | - | tokio::join!(sync_fut, clamav_fut, urlhaus_fut); | |
| 378 | - | let (mut layers, sha256) = match sync_result { | |
| 379 | - | Ok(v) => v, | |
| 380 | - | Err(join_err) => { | |
| 381 | - | tracing::error!( | |
| 382 | - | error = %join_err, | |
| 383 | - | is_panic = join_err.is_panic(), | |
| 384 | - | "scan sync layers panicked; holding file for review (worker survives)" | |
| 385 | - | ); | |
| 386 | - | return panicked_sync_result(file_size); | |
| 387 | - | } | |
| 388 | - | }; | |
| 389 | - | layers.push(clamav_result); | |
| 390 | - | layers.push(urlhaus_result); | |
| 391 | - | self.push_external_lookups(&mut layers, &sha256).await; | |
| 392 | - | ||
| 393 | - | let status = final_status(&layers); | |
| 394 | - | ||
| 395 | - | ScanResult { | |
| 396 | - | status, | |
| 397 | - | layers, | |
| 398 | - | sha256, | |
| 399 | - | file_size, | |
| 400 | - | } | |
| 383 | + | // extra copy (the buffered path previously aggregated then `to_vec`'d the | |
| 384 | + | // body, transiently doubling to ~200 MB for a 100 MB file — Run #2). | |
| 385 | + | self.run_scan(ScanInput::Buffered(data.into()), file_type).await | |
| 401 | 386 | } | |
| 402 | 387 | ||
| 403 | - | /// Streaming counterpart to `scan`. Runs against a spooled tempfile so | |
| 404 | - | /// the >100 MB case doesn't hold the whole object in RAM. The CPU | |
| 405 | - | /// layers operate on a memory mapping (`spool::mmap_read`) — pages are | |
| 406 | - | /// demand-paged by the kernel as goblin / yara-x / archive walk them — | |
| 407 | - | /// and ClamAV streams the file via INSTREAM frames. | |
| 408 | - | pub async fn scan_stream( | |
| 388 | + | /// Streaming scan entry point — large uploads spooled to a tempfile and | |
| 389 | + | /// scanned via a memory map, so the >100 MB case doesn't hold the whole object | |
| 390 | + | /// in RAM. A thin adapter over [`run_scan`](Self::run_scan). | |
| 391 | + | pub(crate) async fn scan_stream( | |
| 409 | 392 | self: std::sync::Arc<Self>, | |
| 410 | 393 | spool: spool::SpoolHandle, | |
| 411 | 394 | file_type: FileType, | |
| 412 | 395 | ) -> ScanResult { | |
| 413 | - | let file_size = std::fs::metadata(spool.path()) | |
| 414 | - | .map(|m| m.len()) | |
| 415 | - | .unwrap_or(0); | |
| 416 | - | ||
| 417 | 396 | let map = match spool::mmap_read(spool.path()) { | |
| 418 | 397 | Ok(m) => std::sync::Arc::new(m), | |
| 419 | 398 | Err(e) => { | |
| 399 | + | let file_size = std::fs::metadata(spool.path()).map(|m| m.len()).unwrap_or(0); | |
| 420 | 400 | let layer = LayerResult { | |
| 421 | 401 | layer: "spool", | |
| 422 | 402 | verdict: LayerVerdict::Error, | |
| @@ -430,17 +410,52 @@ impl ScanPipeline { | |||
| 430 | 410 | }; | |
| 431 | 411 | } | |
| 432 | 412 | }; | |
| 413 | + | self.run_scan(ScanInput::Spooled { map, spool }, file_type).await | |
| 414 | + | } | |
| 433 | 415 | ||
| 434 | - | let sync_map = std::sync::Arc::clone(&map); | |
| 416 | + | /// The single scan body shared by [`scan`](Self::scan) and | |
| 417 | + | /// [`scan_stream`](Self::scan_stream). Every scan policy lives here exactly | |
| 418 | + | /// once; the entry points differ only in how `input` sources its bytes, so a | |
| 419 | + | /// policy (CPU work off the runtime, ClamAV, URLhaus host extraction, external | |
| 420 | + | /// lookups, byte caps) can no longer drift between buffered and streaming | |
| 421 | + | /// (ultra-fuzz CHRONIC, closed Run 9). | |
| 422 | + | /// | |
| 423 | + | /// CPU-bound layers (sha256, content-type, structural, archive, yara) run on a | |
| 424 | + | /// blocking-pool thread via `spawn_blocking`; ClamAV and URLhaus run | |
| 425 | + | /// concurrently with them via `tokio::join!`. | |
| 426 | + | async fn run_scan( | |
| 427 | + | self: std::sync::Arc<Self>, | |
| 428 | + | input: ScanInput, | |
| 429 | + | file_type: FileType, | |
| 430 | + | ) -> ScanResult { | |
| 431 | + | let file_size = input.len() as u64; | |
| 432 | + | ||
| 433 | + | // CPU layers + hash, off the runtime. | |
| 434 | + | let sync_bytes = input.byte_handle(); | |
| 435 | 435 | let sync_self = std::sync::Arc::clone(&self); | |
| 436 | - | let sync_fut = | |
| 437 | - | tokio::task::spawn_blocking(move || sync_self.run_sync_layers(&sync_map, file_type)); | |
| 436 | + | let sync_fut = tokio::task::spawn_blocking(move || { | |
| 437 | + | sync_self.run_sync_layers(sync_bytes.as_slice(), file_type) | |
| 438 | + | }); | |
| 438 | 439 | ||
| 440 | + | // ClamAV: buffered scans the in-memory bytes; spooled streams the file by | |
| 441 | + | // path (INSTREAM frames) so a >100 MB object isn't re-buffered. The | |
| 442 | + | // retained SpoolHandle in `input` keeps the file alive across this join. | |
| 439 | 443 | let clamav_socket = self.clamav_socket.clone(); | |
| 440 | - | let clamav_path = spool.path().to_path_buf(); | |
| 444 | + | let clamav_source = match &input { | |
| 445 | + | ScanInput::Buffered(b) => ClamavSource::Buffered(b.clone()), | |
| 446 | + | ScanInput::Spooled { spool, .. } => ClamavSource::Path(spool.path().to_path_buf()), | |
| 447 | + | }; | |
| 441 | 448 | let clamav_fut = async move { | |
| 442 | - | match clamav_socket { | |
| 443 | - | Some(socket) => match tokio::fs::File::open(&clamav_path).await { | |
| 449 | + | let Some(socket) = clamav_socket else { | |
| 450 | + | return LayerResult { | |
| 451 | + | layer: "clamav", | |
| 452 | + | verdict: LayerVerdict::Skip, | |
| 453 | + | detail: Some("ClamAV not configured".to_string()), | |
| 454 | + | }; | |
| 455 | + | }; | |
| 456 | + | match clamav_source { | |
| 457 | + | ClamavSource::Buffered(data) => clamav::scan_with_clamav(&socket, &data).await, | |
| 458 | + | ClamavSource::Path(path) => match tokio::fs::File::open(&path).await { | |
| 444 | 459 | Ok(file) => clamav::scan_with_clamav_stream(&socket, file).await, | |
| 445 | 460 | Err(e) => LayerResult { | |
| 446 | 461 | layer: "clamav", | |
| @@ -448,25 +463,20 @@ impl ScanPipeline { | |||
| 448 | 463 | detail: Some(format!("open spool for clamav: {e}")), | |
| 449 | 464 | }, | |
| 450 | 465 | }, | |
| 451 | - | None => LayerResult { | |
| 452 | - | layer: "clamav", | |
| 453 | - | verdict: LayerVerdict::Skip, | |
| 454 | - | detail: Some("ClamAV not configured".to_string()), | |
| 455 | - | }, | |
| 456 | 466 | } | |
| 457 | 467 | }; | |
| 458 | 468 | ||
| 459 | - | let urlhaus_map = std::sync::Arc::clone(&map); | |
| 469 | + | // URLhaus: extract candidate hosts on the blocking pool (the byte walk can | |
| 470 | + | // page-fault on the mmap), then do only the network lookups async. This is | |
| 471 | + | // ONE policy for both entry points — the exact step that used to drift | |
| 472 | + | // between scan() and scan_stream() (ultra-fuzz CHRONIC). | |
| 473 | + | let urlhaus_bytes = input.byte_handle(); | |
| 460 | 474 | let urlhaus_enabled = self.urlhaus_enabled; | |
| 461 | 475 | let urlhaus_key = self.abuse_ch_auth_key.clone(); | |
| 462 | 476 | let urlhaus_fut = async move { | |
| 463 | 477 | if urlhaus_enabled { | |
| 464 | - | // Host extraction walks the memory-mapped buffer and can | |
| 465 | - | // page-fault; run it on the blocking pool so it doesn't stall a | |
| 466 | - | // tokio worker, then do only the network lookups async | |
| 467 | - | // (ultra-fuzz Run 4 Perf S2). | |
| 468 | 478 | let hosts = tokio::task::spawn_blocking(move || { | |
| 469 | - | urlhaus::extract_unique_hosts(&urlhaus_map[..], urlhaus::MAX_HOSTS_PER_FILE) | |
| 479 | + | urlhaus::extract_unique_hosts(urlhaus_bytes.as_slice(), urlhaus::MAX_HOSTS_PER_FILE) | |
| 470 | 480 | }) | |
| 471 | 481 | .await | |
| 472 | 482 | .unwrap_or_default(); | |
| @@ -488,7 +498,7 @@ impl ScanPipeline { | |||
| 488 | 498 | tracing::error!( | |
| 489 | 499 | error = %join_err, | |
| 490 | 500 | is_panic = join_err.is_panic(), | |
| 491 | - | "scan_stream sync layers panicked; holding file for review (worker survives)" | |
| 501 | + | "scan sync layers panicked; holding file for review (worker survives)" | |
| 492 | 502 | ); | |
| 493 | 503 | return panicked_sync_result(file_size); | |
| 494 | 504 | } | |
| @@ -498,8 +508,10 @@ impl ScanPipeline { | |||
| 498 | 508 | self.push_external_lookups(&mut layers, &sha256).await; | |
| 499 | 509 | ||
| 500 | 510 | let status = final_status(&layers); | |
| 501 | - | drop(map); | |
| 502 | - | drop(spool); | |
| 511 | + | ||
| 512 | + | // Dropping `input` here releases the mmap and unlinks the spool tempfile | |
| 513 | + | // (if any), after ClamAV has finished streaming it. | |
| 514 | + | drop(input); | |
| 503 | 515 | ||
| 504 | 516 | ScanResult { | |
| 505 | 517 | status, |
| @@ -29,26 +29,10 @@ const MAX_SCAN_BYTES: usize = 4 * 1024 * 1024; | |||
| 29 | 29 | /// Minimum printable-ASCII run length to consider as a potential string. | |
| 30 | 30 | const MIN_RUN_LEN: usize = 6; | |
| 31 | 31 | ||
| 32 | - | /// Check the file for URLs that appear in URLhaus's known-bad index. | |
| 33 | - | pub async fn check_urlhaus(data: &[u8], auth_key: Option<&str>) -> LayerResult { | |
| 34 | - | if auth_key.is_none() { | |
| 35 | - | return LayerResult { | |
| 36 | - | layer: "urlhaus", | |
| 37 | - | verdict: LayerVerdict::Skip, | |
| 38 | - | detail: Some("No abuse.ch Auth-Key configured".to_string()), | |
| 39 | - | }; | |
| 40 | - | } | |
| 41 | - | // In-memory path (small uploads, tests): extraction is cheap on a heap | |
| 42 | - | // buffer. The large-file mmap path extracts inside `spawn_blocking` and calls | |
| 43 | - | // `check_urlhaus_hosts` directly (ultra-fuzz Run 4 Perf S2). | |
| 44 | - | let hosts = extract_unique_hosts(data, MAX_HOSTS_PER_FILE); | |
| 45 | - | check_urlhaus_hosts(hosts, auth_key).await | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | /// URLhaus lookup over already-extracted hosts. Split from [`check_urlhaus`] so | |
| 49 | - | /// the mmap scan path can run the page-fault-prone host extraction off the async | |
| 50 | - | /// runtime (in `spawn_blocking`) and pass the result here, leaving only the | |
| 51 | - | /// network lookups on the runtime. | |
| 32 | + | /// URLhaus lookup over already-extracted hosts. The scan pipeline always extracts | |
| 33 | + | /// candidate hosts off the async runtime (in `spawn_blocking`, since the byte walk | |
| 34 | + | /// can page-fault on an mmap) and passes the result here, leaving only the network | |
| 35 | + | /// lookups on the runtime — one path for buffered and streamed scans alike. | |
| 52 | 36 | pub(crate) async fn check_urlhaus_hosts(hosts: Vec<String>, auth_key: Option<&str>) -> LayerResult { | |
| 53 | 37 | let Some(key) = auth_key else { | |
| 54 | 38 | return LayerResult { | |
| @@ -295,16 +279,24 @@ mod tests { | |||
| 295 | 279 | use super::*; | |
| 296 | 280 | use serde_json::json; | |
| 297 | 281 | ||
| 282 | + | /// Exercise the production scan path: extract hosts, then look them up. The | |
| 283 | + | /// scan pipeline always extracts on a blocking thread then calls | |
| 284 | + | /// `check_urlhaus_hosts`; there is no separate buffered helper. | |
| 285 | + | async fn check(data: &[u8], auth_key: Option<&str>) -> LayerResult { | |
| 286 | + | let hosts = extract_unique_hosts(data, MAX_HOSTS_PER_FILE); | |
| 287 | + | check_urlhaus_hosts(hosts, auth_key).await | |
| 288 | + | } | |
| 289 | + | ||
| 298 | 290 | #[tokio::test] | |
| 299 | 291 | async fn no_auth_key_returns_skip() { | |
| 300 | - | let result = check_urlhaus(b"http://example.com/", None).await; | |
| 292 | + | let result = check(b"http://example.com/", None).await; | |
| 301 | 293 | assert_eq!(result.verdict, LayerVerdict::Skip); | |
| 302 | 294 | } | |
| 303 | 295 | ||
| 304 | 296 | #[tokio::test] | |
| 305 | 297 | async fn no_urls_in_data_passes() { | |
| 306 | 298 | // With an auth key, an empty buffer should still pass (no URLs to check). | |
| 307 | - | let result = check_urlhaus(b"plain binary blob no urls here", Some("test")).await; | |
| 299 | + | let result = check(b"plain binary blob no urls here", Some("test")).await; | |
| 308 | 300 | assert_eq!(result.verdict, LayerVerdict::Pass); | |
| 309 | 301 | assert!(result.detail.unwrap().contains("No URLs")); | |
| 310 | 302 | } |
| @@ -20,32 +20,37 @@ pub(super) async fn recalculate_all_storage_used(state: &AppState) { | |||
| 20 | 20 | ||
| 21 | 21 | /// Enforce post-grace item hiding for creators whose cancellation grace period has expired. | |
| 22 | 22 | pub(super) async fn enforce_post_grace_hiding(state: &AppState) { | |
| 23 | - | match db::creator_tiers::get_expired_grace_creators(&state.db).await { | |
| 24 | - | Ok(user_ids) => { | |
| 25 | - | for user_id in user_ids { | |
| 26 | - | match db::items::hide_all_items_for_user(&state.db, user_id).await { | |
| 27 | - | Ok(count) => { | |
| 28 | - | if count > 0 { | |
| 29 | - | tracing::info!( | |
| 30 | - | user_id = %user_id, | |
| 31 | - | items_hidden = count, | |
| 32 | - | "post-grace enforcement: items hidden" | |
| 33 | - | ); | |
| 34 | - | } | |
| 35 | - | } | |
| 36 | - | Err(e) => { | |
| 37 | - | tracing::error!(error = ?e, user_id = %user_id, "failed to hide items for post-grace enforcement"); | |
| 38 | - | continue; | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | if let Err(e) = db::creator_tiers::mark_grace_enforced(&state.db, user_id).await { | |
| 42 | - | tracing::error!(error = ?e, user_id = %user_id, "failed to mark grace enforced"); | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | } | |
| 23 | + | let user_ids = match db::creator_tiers::get_expired_grace_creators(&state.db).await { | |
| 24 | + | Ok(ids) => ids, | |
| 46 | 25 | Err(e) => { | |
| 47 | 26 | tracing::error!(error = ?e, "failed to query expired grace creators"); | |
| 27 | + | return; | |
| 48 | 28 | } | |
| 29 | + | }; | |
| 30 | + | if user_ids.is_empty() { | |
| 31 | + | return; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Set-based hide + mark in two statements instead of 2N per-creator round-trips | |
| 35 | + | // inline on the lock-held tick (Perf-S4, Run 9). Both are idempotent, so if the | |
| 36 | + | // mark fails after the hide the next sweep simply re-hides (a no-op) and re-marks. | |
| 37 | + | let hidden = match db::items::hide_all_items_for_users(&state.db, &user_ids).await { | |
| 38 | + | Ok(count) => count, | |
| 39 | + | Err(e) => { | |
| 40 | + | tracing::error!(error = ?e, "failed to hide items for post-grace enforcement"); | |
| 41 | + | return; | |
| 42 | + | } | |
| 43 | + | }; | |
| 44 | + | if let Err(e) = db::creator_tiers::mark_grace_enforced_batch(&state.db, &user_ids).await { | |
| 45 | + | tracing::error!(error = ?e, "failed to mark grace enforced"); | |
| 46 | + | return; | |
| 47 | + | } | |
| 48 | + | if hidden > 0 { | |
| 49 | + | tracing::info!( | |
| 50 | + | creators = user_ids.len(), | |
| 51 | + | items_hidden = hidden, | |
| 52 | + | "post-grace enforcement: items hidden" | |
| 53 | + | ); | |
| 49 | 54 | } | |
| 50 | 55 | } | |
| 51 | 56 |
| @@ -262,7 +262,7 @@ pub fn spawn_scheduler( | |||
| 262 | 262 | ||
| 263 | 263 | // Enforce post-grace item hiding (canceled 30+ days ago). Daily | |
| 264 | 264 | // is ample for a 30-day grace window, and it's self-draining | |
| 265 | - | // (mark_grace_enforced), so running it every 60s tick only | |
| 265 | + | // (mark_grace_enforced_batch), so running it every 60s tick only | |
| 266 | 266 | // re-issued an empty query — moved here off the hot tick path. | |
| 267 | 267 | integrity::enforce_post_grace_hiding(&state).await; | |
| 268 | 268 |