max / makenotwork
12 files changed,
+322 insertions,
-18 deletions
| @@ -271,6 +271,13 @@ async fn publish(flags: &[String]) -> Result<()> { | |||
| 271 | 271 | .context("artifact upload failed")?; | |
| 272 | 272 | println!("ok"); | |
| 273 | 273 | ||
| 274 | + | print!(" confirming artifact (queues malware scan)... "); | |
| 275 | + | client | |
| 276 | + | .ota_confirm_artifact(release_id, &args.target, &args.arch) | |
| 277 | + | .await | |
| 278 | + | .context("artifact confirm failed")?; | |
| 279 | + | println!("ok"); | |
| 280 | + | ||
| 274 | 281 | print!(" verifying updater endpoint... "); | |
| 275 | 282 | match client | |
| 276 | 283 | .ota_updater_check(&args.slug, &args.target, &args.arch, "0.0.1") | |
| @@ -288,8 +295,8 @@ async fn publish(flags: &[String]) -> Result<()> { | |||
| 288 | 295 | } | |
| 289 | 296 | None => { | |
| 290 | 297 | println!( | |
| 291 | - | "warning: updater returned no update (204). The release was created but is not \ | |
| 292 | - | being served for {}/{} yet.", | |
| 298 | + | "pending scan (204). The artifact was uploaded and queued for malware \ | |
| 299 | + | scanning; it will be served for {}/{} once the scan clears.", | |
| 293 | 300 | args.target, args.arch | |
| 294 | 301 | ); | |
| 295 | 302 | } |
| @@ -0,0 +1,15 @@ | |||
| 1 | + | -- Gate OTA artifact serving on a malware scan, mirroring the item/version | |
| 2 | + | -- channel (audit 2026-07-01). Artifacts start 'pending' and are only advertised | |
| 3 | + | -- / downloadable once the scan pipeline flips them to 'clean'. | |
| 4 | + | -- | |
| 5 | + | -- Existing rows predate scanning; mark them 'clean' so a migration doesn't strand | |
| 6 | + | -- already-published releases (the alpha OTA channel is app-owner-gated and the | |
| 7 | + | -- clients verify the ed25519 signature independently). | |
| 8 | + | ||
| 9 | + | ALTER TABLE ota_artifacts | |
| 10 | + | ADD COLUMN IF NOT EXISTS scan_status TEXT NOT NULL DEFAULT 'pending'; | |
| 11 | + | ||
| 12 | + | UPDATE ota_artifacts SET scan_status = 'clean' WHERE scan_status = 'pending'; | |
| 13 | + | ||
| 14 | + | CREATE INDEX IF NOT EXISTS idx_ota_artifacts_scan_status | |
| 15 | + | ON ota_artifacts(release_id, target, arch, scan_status); |
| @@ -275,12 +275,27 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 275 | 275 | return; | |
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | - | // Use signature from the first artifact that has one (release-level field) | |
| 279 | - | let release_signature = artifact_keys | |
| 278 | + | // Use signature from the first artifact that has one (release-level field). | |
| 279 | + | // A release with no signature can never be installed (Tauri refuses an | |
| 280 | + | // unsigned update); fail the build loudly rather than publishing a dead | |
| 281 | + | // release with an empty signature. | |
| 282 | + | let release_signature = match artifact_keys | |
| 280 | 283 | .iter() | |
| 281 | 284 | .find(|(_, _, _, sig)| !sig.is_empty()) | |
| 282 | 285 | .map(|(_, _, _, sig)| sig.as_str()) | |
| 283 | - | .unwrap_or(""); | |
| 286 | + | { | |
| 287 | + | Some(sig) => sig, | |
| 288 | + | None => { | |
| 289 | + | let msg = "build produced no signed artifact; refusing to publish an unsigned OTA release"; | |
| 290 | + | if let Err(e) = | |
| 291 | + | db::builds::update_build_status(&state.db, build.id, BuildStatus::Failed, Some(msg)) | |
| 292 | + | .await | |
| 293 | + | { | |
| 294 | + | tracing::error!(build_id = %build.id, error = ?e, "failed to mark build failed (missing signature)"); | |
| 295 | + | } | |
| 296 | + | return; | |
| 297 | + | } | |
| 298 | + | }; | |
| 284 | 299 | ||
| 285 | 300 | // Create OTA release (only for fully successful builds) | |
| 286 | 301 | let release = match db::ota::create_release( | |
| @@ -308,7 +323,16 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 308 | 323 | } | |
| 309 | 324 | }; | |
| 310 | 325 | ||
| 311 | - | // Record artifacts | |
| 326 | + | // The app owner is the responsible identity for the artifact scans. | |
| 327 | + | let owner_id = db::synckit::get_sync_app_by_id(&state.db, build.app_id) | |
| 328 | + | .await | |
| 329 | + | .ok() | |
| 330 | + | .flatten() | |
| 331 | + | .map(|app| app.creator_id); | |
| 332 | + | ||
| 333 | + | // Record artifacts and enqueue each for malware scanning. The artifact stays | |
| 334 | + | // `pending` (not served) until the scan clears it — same gate as the item | |
| 335 | + | // channel. | |
| 312 | 336 | for (target_os, arch, s3_key, _signature) in &artifact_keys { | |
| 313 | 337 | // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable) | |
| 314 | 338 | let file_size = if let Some(s3) = state.synckit_s3.as_ref() { | |
| @@ -317,11 +341,20 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 317 | 341 | 0 | |
| 318 | 342 | }; | |
| 319 | 343 | ||
| 320 | - | if let Err(e) = | |
| 321 | - | db::ota::create_artifact(&state.db, release.id, target_os, arch, s3_key, file_size) | |
| 322 | - | .await | |
| 344 | + | match db::ota::create_artifact(&state.db, release.id, target_os, arch, s3_key, file_size) | |
| 345 | + | .await | |
| 323 | 346 | { | |
| 324 | - | tracing::error!(error = ?e, "failed to record artifact"); | |
| 347 | + | Ok(artifact) => { | |
| 348 | + | if let Some(owner_id) = owner_id | |
| 349 | + | && let Err(e) = crate::routes::ota::enqueue_ota_artifact_scan( | |
| 350 | + | state, artifact.id, s3_key, owner_id, file_size, | |
| 351 | + | ) | |
| 352 | + | .await | |
| 353 | + | { | |
| 354 | + | tracing::error!(artifact_id = %artifact.id, error = ?e, "failed to enqueue OTA artifact scan"); | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | Err(e) => tracing::error!(error = ?e, "failed to record artifact"), | |
| 325 | 358 | } | |
| 326 | 359 | } | |
| 327 | 360 |
| @@ -196,5 +196,7 @@ pub struct DbOtaArtifact { | |||
| 196 | 196 | pub arch: String, | |
| 197 | 197 | pub s3_key: String, | |
| 198 | 198 | pub file_size: i64, | |
| 199 | + | /// Malware-scan gate: only `clean` artifacts are advertised/downloadable. | |
| 200 | + | pub scan_status: super::super::FileScanStatus, | |
| 199 | 201 | pub created_at: DateTime<Utc>, | |
| 200 | 202 | } |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | use sqlx::PgPool; | |
| 4 | 4 | ||
| 5 | 5 | use super::models::*; | |
| 6 | - | use super::{OtaReleaseId, SyncAppId}; | |
| 6 | + | use super::{OtaArtifactId, OtaReleaseId, SyncAppId}; | |
| 7 | 7 | use crate::error::Result; | |
| 8 | 8 | ||
| 9 | 9 | // ── App slug ── | |
| @@ -48,6 +48,14 @@ pub async fn create_release( | |||
| 48 | 48 | notes: &str, | |
| 49 | 49 | signature: &str, | |
| 50 | 50 | ) -> Result<DbOtaRelease> { | |
| 51 | + | // A release with no signature can never be installed (the Tauri updater | |
| 52 | + | // silently refuses an unsigned update), and publishing one just advertises a | |
| 53 | + | // dead release. Reject it at the write boundary rather than storing "". | |
| 54 | + | if signature.trim().is_empty() { | |
| 55 | + | return Err(crate::error::AppError::BadRequest( | |
| 56 | + | "OTA release signature is required".to_string(), | |
| 57 | + | )); | |
| 58 | + | } | |
| 51 | 59 | let release = sqlx::query_as::<_, DbOtaRelease>( | |
| 52 | 60 | r#" | |
| 53 | 61 | INSERT INTO ota_releases (app_id, version, notes, signature) | |
| @@ -211,6 +219,21 @@ pub async fn create_artifact( | |||
| 211 | 219 | Ok(artifact) | |
| 212 | 220 | } | |
| 213 | 221 | ||
| 222 | + | /// Update an OTA artifact's malware-scan status (called by the scan worker). | |
| 223 | + | #[tracing::instrument(skip_all)] | |
| 224 | + | pub async fn update_artifact_scan_status( | |
| 225 | + | pool: &PgPool, | |
| 226 | + | artifact_id: OtaArtifactId, | |
| 227 | + | status: crate::db::FileScanStatus, | |
| 228 | + | ) -> std::result::Result<(), sqlx::Error> { | |
| 229 | + | sqlx::query("UPDATE ota_artifacts SET scan_status = $1 WHERE id = $2") | |
| 230 | + | .bind(status) | |
| 231 | + | .bind(artifact_id) | |
| 232 | + | .execute(pool) | |
| 233 | + | .await?; | |
| 234 | + | Ok(()) | |
| 235 | + | } | |
| 236 | + | ||
| 214 | 237 | /// Get an artifact by release, target, and arch. | |
| 215 | 238 | #[tracing::instrument(skip_all)] | |
| 216 | 239 | pub async fn get_artifact( |
| @@ -30,6 +30,7 @@ pub enum ScanTargetKind { | |||
| 30 | 30 | ItemImage, | |
| 31 | 31 | GalleryImage, | |
| 32 | 32 | ContentInsertion, | |
| 33 | + | OtaArtifact, | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | impl ScanTargetKind { | |
| @@ -42,6 +43,7 @@ impl ScanTargetKind { | |||
| 42 | 43 | ScanTargetKind::ItemImage => "item_image", | |
| 43 | 44 | ScanTargetKind::GalleryImage => "gallery_image", | |
| 44 | 45 | ScanTargetKind::ContentInsertion => "content_insertion", | |
| 46 | + | ScanTargetKind::OtaArtifact => "ota_artifact", | |
| 45 | 47 | } | |
| 46 | 48 | } | |
| 47 | 49 | ||
| @@ -54,6 +56,7 @@ impl ScanTargetKind { | |||
| 54 | 56 | "item_image" => ScanTargetKind::ItemImage, | |
| 55 | 57 | "gallery_image" => ScanTargetKind::GalleryImage, | |
| 56 | 58 | "content_insertion" => ScanTargetKind::ContentInsertion, | |
| 59 | + | "ota_artifact" => ScanTargetKind::OtaArtifact, | |
| 57 | 60 | _ => return None, | |
| 58 | 61 | }) | |
| 59 | 62 | } |
| @@ -400,6 +400,7 @@ async fn main() { | |||
| 400 | 400 | bg: state.bg.clone(), | |
| 401 | 401 | cloudflare: makenotwork::cloudflare::CloudflarePurger::from_env(), | |
| 402 | 402 | cdn_base_url: state.config.cdn_base_url.as_deref().map(std::sync::Arc::from), | |
| 403 | + | synckit_s3: state.synckit_s3.clone(), | |
| 403 | 404 | }); | |
| 404 | 405 | let worker_count = makenotwork::constants::SCAN_WORKER_COUNT; | |
| 405 | 406 | let worker_shutdown_rx = shutdown_tx.subscribe(); |
| @@ -314,6 +314,82 @@ async fn upload_artifact( | |||
| 314 | 314 | )) | |
| 315 | 315 | } | |
| 316 | 316 | ||
| 317 | + | /// Enqueue (or resolve) the malware scan for an OTA artifact. Only a `clean` | |
| 318 | + | /// artifact is ever advertised or downloaded, so this is what un-gates a | |
| 319 | + | /// release. With no scanner configured, OTA uploaders are app-owners (trusted), | |
| 320 | + | /// so the artifact is marked clean immediately — mirroring the item path's | |
| 321 | + | /// disabled-scanner branch. | |
| 322 | + | pub(crate) async fn enqueue_ota_artifact_scan( | |
| 323 | + | state: &AppState, | |
| 324 | + | artifact_id: db::OtaArtifactId, | |
| 325 | + | s3_key: &str, | |
| 326 | + | user_id: db::UserId, | |
| 327 | + | file_size: i64, | |
| 328 | + | ) -> Result<()> { | |
| 329 | + | if state.scanner.is_none() { | |
| 330 | + | db::ota::update_artifact_scan_status(&state.db, artifact_id, db::FileScanStatus::Clean) | |
| 331 | + | .await?; | |
| 332 | + | return Ok(()); | |
| 333 | + | } | |
| 334 | + | db::scan_jobs::enqueue( | |
| 335 | + | &state.db, | |
| 336 | + | db::scan_jobs::ScanTargetKind::OtaArtifact, | |
| 337 | + | *artifact_id.as_uuid(), | |
| 338 | + | s3_key, | |
| 339 | + | crate::storage::FileType::Download, | |
| 340 | + | user_id, | |
| 341 | + | file_size, | |
| 342 | + | ) | |
| 343 | + | .await?; | |
| 344 | + | Ok(()) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | #[derive(Deserialize)] | |
| 348 | + | struct ConfirmArtifactRequest { | |
| 349 | + | target: String, | |
| 350 | + | arch: String, | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /// Confirm an uploaded artifact: verify the object landed, then enqueue its scan. | |
| 354 | + | /// | |
| 355 | + | /// `POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm` | |
| 356 | + | /// | |
| 357 | + | /// The CLI calls this after the S3 PUT. Until the scan completes clean, the | |
| 358 | + | /// artifact stays `pending` and is neither advertised nor downloadable. | |
| 359 | + | #[tracing::instrument(skip_all, name = "ota::confirm_artifact")] | |
| 360 | + | async fn confirm_artifact( | |
| 361 | + | State(state): State<AppState>, | |
| 362 | + | sync_user: SyncUser, | |
| 363 | + | Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, | |
| 364 | + | Json(req): Json<ConfirmArtifactRequest>, | |
| 365 | + | ) -> Result<impl IntoResponse> { | |
| 366 | + | let _app = verify_app_owner(&state, &sync_user, app_id).await?; | |
| 367 | + | validate_target(&req.target)?; | |
| 368 | + | validate_arch(&req.arch)?; | |
| 369 | + | ||
| 370 | + | db::ota::get_release(&state.db, app_id, release_id) | |
| 371 | + | .await? | |
| 372 | + | .ok_or(AppError::NotFound)?; | |
| 373 | + | let artifact = db::ota::get_artifact(&state.db, release_id, &req.target, &req.arch) | |
| 374 | + | .await? | |
| 375 | + | .ok_or(AppError::NotFound)?; | |
| 376 | + | ||
| 377 | + | let synckit_s3 = state.require_synckit_s3()?; | |
| 378 | + | let size = synckit_s3 | |
| 379 | + | .object_size(&artifact.s3_key) | |
| 380 | + | .await? | |
| 381 | + | .ok_or_else(|| { | |
| 382 | + | AppError::BadRequest( | |
| 383 | + | "artifact object not found in storage; upload it before confirming".to_string(), | |
| 384 | + | ) | |
| 385 | + | })?; | |
| 386 | + | ||
| 387 | + | enqueue_ota_artifact_scan(&state, artifact.id, &artifact.s3_key, sync_user.user_id, size as i64) | |
| 388 | + | .await?; | |
| 389 | + | ||
| 390 | + | Ok(axum::http::StatusCode::ACCEPTED) | |
| 391 | + | } | |
| 392 | + | ||
| 317 | 393 | // ── Public endpoints (no auth) ── | |
| 318 | 394 | ||
| 319 | 395 | /// Tauri updater check endpoint. | |
| @@ -350,12 +426,16 @@ async fn updater_check( | |||
| 350 | 426 | return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); | |
| 351 | 427 | } | |
| 352 | 428 | ||
| 353 | - | // Check that an artifact exists for this target/arch | |
| 429 | + | // Check that a scanned-clean artifact exists for this target/arch. A pending | |
| 430 | + | // or quarantined artifact is treated as "no update available" (204) — never | |
| 431 | + | // advertise a binary the scan pipeline hasn't cleared. | |
| 354 | 432 | let artifact = match db::ota::get_artifact(&state.db, latest.id, &target, &arch).await? { | |
| 355 | 433 | Some(a) => a, | |
| 356 | 434 | None => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()), | |
| 357 | 435 | }; | |
| 358 | - | let _ = artifact; | |
| 436 | + | if artifact.scan_status != db::FileScanStatus::Clean { | |
| 437 | + | return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); | |
| 438 | + | } | |
| 359 | 439 | ||
| 360 | 440 | let download_url = format!( | |
| 361 | 441 | "{}/api/sync/ota/{}/download/{}/{}/{}", | |
| @@ -400,6 +480,11 @@ async fn artifact_download( | |||
| 400 | 480 | .await? | |
| 401 | 481 | .ok_or(AppError::NotFound)?; | |
| 402 | 482 | ||
| 483 | + | // Never hand out a URL to an artifact the scan pipeline hasn't cleared. | |
| 484 | + | if artifact.scan_status != db::FileScanStatus::Clean { | |
| 485 | + | return Err(AppError::NotFound); | |
| 486 | + | } | |
| 487 | + | ||
| 403 | 488 | let synckit_s3 = state.require_synckit_s3()?; | |
| 404 | 489 | ||
| 405 | 490 | // The artifact row is written at presign time (before the client PUTs the | |
| @@ -462,6 +547,14 @@ pub fn ota_routes() -> CsrfRouter<AppState> { | |||
| 462 | 547 | "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts", | |
| 463 | 548 | post_csrf_skip(OTA_SKIP, upload_artifact), | |
| 464 | 549 | ) | |
| 550 | + | .route( | |
| 551 | + | "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm", | |
| 552 | + | post_csrf_skip(OTA_SKIP, confirm_artifact), | |
| 553 | + | ) | |
| 554 | + | .route( | |
| 555 | + | "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm", | |
| 556 | + | post_csrf_skip(OTA_SKIP, confirm_artifact), | |
| 557 | + | ) | |
| 465 | 558 | .route_layer(GovernorLayer { | |
| 466 | 559 | config: write_rate_limit, | |
| 467 | 560 | }); |
| @@ -51,6 +51,10 @@ pub struct WorkerContext { | |||
| 51 | 51 | /// CDN base URL (e.g. "https://cdn.makenot.work"), used to build the purge | |
| 52 | 52 | /// target for a quarantined object's public URL. `None` disables purging. | |
| 53 | 53 | pub cdn_base_url: Option<Arc<str>>, | |
| 54 | + | /// SyncKit-bucket backend. OTA artifacts live here (not the main `s3`), so a | |
| 55 | + | /// `ScanTargetKind::OtaArtifact` job downloads from this backend instead. | |
| 56 | + | /// `None` when SyncKit storage isn't configured (OTA scans then fail closed). | |
| 57 | + | pub synckit_s3: Option<Arc<dyn StorageBackend>>, | |
| 54 | 58 | } | |
| 55 | 59 | ||
| 56 | 60 | /// Decide the final status for a non-quarantined scan. | |
| @@ -242,6 +246,17 @@ async fn run_pipeline_and_decide( | |||
| 242 | 246 | // the permit bounds the CPU/clamd-heavy scan phase, not network IO. | |
| 243 | 247 | // Holding it across the GET serializes downloads at SCAN_MAX_CONCURRENT | |
| 244 | 248 | // and lets a scan backlog starve the DB pool. | |
| 249 | + | // OTA artifacts live in the SyncKit bucket; everything else in the main | |
| 250 | + | // bucket. Pick the backend to download from by target kind. | |
| 251 | + | let backend: &Arc<dyn StorageBackend> = match kind { | |
| 252 | + | ScanTargetKind::OtaArtifact => ctx.synckit_s3.as_ref().ok_or_else(|| { | |
| 253 | + | Box::<dyn std::error::Error + Send + Sync>::from( | |
| 254 | + | "SyncKit storage not configured; cannot scan OTA artifact", | |
| 255 | + | ) | |
| 256 | + | })?, | |
| 257 | + | _ => &ctx.s3, | |
| 258 | + | }; | |
| 259 | + | ||
| 245 | 260 | let result: ScanResult = if job.file_size_bytes as u64 > constants::SCAN_SPOOL_MAX_BYTES { | |
| 246 | 261 | // Too large to spool for the CPU layers. Hold for admin review under an | |
| 247 | 262 | // explicit policy rather than letting `download_into_tempfile` return an | |
| @@ -257,11 +272,11 @@ async fn run_pipeline_and_decide( | |||
| 257 | 272 | } else if (job.file_size_bytes as usize) < constants::SCAN_MAX_MEMORY_BYTES { | |
| 258 | 273 | // `download_object_buf` returns the aggregated body as `Bytes` (no | |
| 259 | 274 | // `to_vec` copy); `scan` takes it directly (Run #2 Performance SERIOUS). | |
| 260 | - | let data = ctx.s3.download_object_buf(&job.s3_key).await?; | |
| 275 | + | let data = backend.download_object_buf(&job.s3_key).await?; | |
| 261 | 276 | let _permit = ctx.scan_semaphore.acquire().await?; | |
| 262 | 277 | Arc::clone(&ctx.pipeline).scan(data, file_type).await | |
| 263 | 278 | } else { | |
| 264 | - | let stream = ctx.s3.download_stream(&job.s3_key).await?; | |
| 279 | + | let stream = backend.download_stream(&job.s3_key).await?; | |
| 265 | 280 | let spool = super::spool::download_into_tempfile( | |
| 266 | 281 | std::path::Path::new(constants::SCAN_SPOOL_DIR), | |
| 267 | 282 | &job.id.to_string(), | |
| @@ -477,6 +492,14 @@ async fn update_entity_status( | |||
| 477 | 492 | ) | |
| 478 | 493 | .await | |
| 479 | 494 | } | |
| 495 | + | ScanTargetKind::OtaArtifact => { | |
| 496 | + | db::ota::update_artifact_scan_status( | |
| 497 | + | db, | |
| 498 | + | db::OtaArtifactId::from(target_id), | |
| 499 | + | status, | |
| 500 | + | ) | |
| 501 | + | .await | |
| 502 | + | } | |
| 480 | 503 | ScanTargetKind::ItemImage | |
| 481 | 504 | | ScanTargetKind::ProjectImage | |
| 482 | 505 | | ScanTargetKind::GalleryImage |
| @@ -549,6 +549,8 @@ impl TestHarness { | |||
| 549 | 549 | // No Cloudflare purge in tests; quarantine still deletes from origin. | |
| 550 | 550 | cloudflare: None, | |
| 551 | 551 | cdn_base_url: None, | |
| 552 | + | // OTA artifacts scan from the SyncKit bucket; tests share one backend. | |
| 553 | + | synckit_s3: Some(deps.s3.clone()), | |
| 552 | 554 | }; | |
| 553 | 555 | // Hard cap to avoid an infinite loop if a job re-enqueues itself. | |
| 554 | 556 | for _ in 0..256 { |
| @@ -111,6 +111,17 @@ async fn harness_with_synckit_storage() -> TestHarness { | |||
| 111 | 111 | TestHarness::with_synckit_storage().await | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | /// Mark a release's artifacts scanned-clean (simulates a completed scan). New | |
| 115 | + | /// artifacts start `pending` and are not advertised/downloadable until the scan | |
| 116 | + | /// pipeline clears them; serve-path tests call this to reach the served state. | |
| 117 | + | async fn mark_artifacts_clean(pool: &PgPool, release_id: &makenotwork::db::OtaReleaseId) { | |
| 118 | + | sqlx::query("UPDATE ota_artifacts SET scan_status = 'clean' WHERE release_id = $1") | |
| 119 | + | .bind(release_id) | |
| 120 | + | .execute(pool) | |
| 121 | + | .await | |
| 122 | + | .unwrap(); | |
| 123 | + | } | |
| 124 | + | ||
| 114 | 125 | // ── Tests ── | |
| 115 | 126 | ||
| 116 | 127 | #[tokio::test] | |
| @@ -336,7 +347,7 @@ async fn duplicate_version() { | |||
| 336 | 347 | .client | |
| 337 | 348 | .post_json( | |
| 338 | 349 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 339 | - | &json!({ "version": "1.0.0", "notes": "first" }).to_string(), | |
| 350 | + | &json!({ "version": "1.0.0", "notes": "first", "signature": "s" }).to_string(), | |
| 340 | 351 | ) | |
| 341 | 352 | .await; | |
| 342 | 353 | assert_eq!(resp.status, 201); | |
| @@ -346,7 +357,7 @@ async fn duplicate_version() { | |||
| 346 | 357 | .client | |
| 347 | 358 | .post_json( | |
| 348 | 359 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 349 | - | &json!({ "version": "1.0.0", "notes": "duplicate" }).to_string(), | |
| 360 | + | &json!({ "version": "1.0.0", "notes": "duplicate", "signature": "s" }).to_string(), | |
| 350 | 361 | ) | |
| 351 | 362 | .await; | |
| 352 | 363 | assert_eq!(resp.status, 409, "Duplicate version should return 409 Conflict: {}", resp.text); | |
| @@ -445,6 +456,8 @@ async fn updater_check_newer_version() { | |||
| 445 | 456 | ) | |
| 446 | 457 | .await; | |
| 447 | 458 | assert_eq!(resp.status, 201); | |
| 459 | + | // The artifact is scan-gated; mark it clean so the updater advertises it. | |
| 460 | + | mark_artifacts_clean(&h.db, &release.id).await; | |
| 448 | 461 | ||
| 449 | 462 | // Check for update with older version (unauthenticated) | |
| 450 | 463 | h.client.clear_bearer_token(); | |
| @@ -460,6 +473,61 @@ async fn updater_check_newer_version() { | |||
| 460 | 473 | assert!(update.url.contains("/download/")); | |
| 461 | 474 | } | |
| 462 | 475 | ||
| 476 | + | /// A freshly-uploaded artifact is scan-gated: the updater returns 204 until the | |
| 477 | + | /// artifact is scanned clean, then advertises it. Regression for the OTA | |
| 478 | + | /// scan-bypass (audit 2026-07-01). | |
| 479 | + | #[tokio::test] | |
| 480 | + | async fn updater_check_gated_until_artifact_clean() { | |
| 481 | + | let mut h = harness_with_synckit_storage().await; | |
| 482 | + | let user_id = h.signup("otagate", "otagate@example.com", "Password1!").await; | |
| 483 | + | let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "gateapp").await; | |
| 484 | + | ||
| 485 | + | let resp = h | |
| 486 | + | .client | |
| 487 | + | .post_json( | |
| 488 | + | "/api/sync/auth", | |
| 489 | + | &json!({ | |
| 490 | + | "email": "otagate@example.com", | |
| 491 | + | "password": "Password1!", | |
| 492 | + | "api_key": api_key, | |
| 493 | + | "key": "test-sdk-key", | |
| 494 | + | }) | |
| 495 | + | .to_string(), | |
| 496 | + | ) | |
| 497 | + | .await; | |
| 498 | + | let auth: AuthResponse = resp.json(); | |
| 499 | + | h.client.set_bearer_token(&auth.token); | |
| 500 | + | ||
| 501 | + | let resp = h | |
| 502 | + | .client | |
| 503 | + | .post_json( | |
| 504 | + | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 505 | + | &json!({ "version": "1.5.0", "notes": "", "signature": "sig" }).to_string(), | |
| 506 | + | ) | |
| 507 | + | .await; | |
| 508 | + | assert_eq!(resp.status, 201); | |
| 509 | + | let release: ReleaseResponse = resp.json(); | |
| 510 | + | ||
| 511 | + | let resp = h | |
| 512 | + | .client | |
| 513 | + | .post_json( | |
| 514 | + | &format!("/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id), | |
| 515 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000 }).to_string(), | |
| 516 | + | ) | |
| 517 | + | .await; | |
| 518 | + | assert_eq!(resp.status, 201); | |
| 519 | + | ||
| 520 | + | h.client.clear_bearer_token(); | |
| 521 | + | // Pending (unscanned) artifact: no update advertised. | |
| 522 | + | let resp = h.client.get("/api/sync/ota/gateapp/linux/x86_64/1.0.0").await; | |
| 523 | + | assert_eq!(resp.status, 204, "pending artifact must not be advertised"); | |
| 524 | + | ||
| 525 | + | // After the scan clears it, the update is advertised. | |
| 526 | + | mark_artifacts_clean(&h.db, &release.id).await; | |
| 527 | + | let resp = h.client.get("/api/sync/ota/gateapp/linux/x86_64/1.0.0").await; | |
| 528 | + | assert_eq!(resp.status, 200, "clean artifact should be advertised: {}", resp.text); | |
| 529 | + | } | |
| 530 | + | ||
| 463 | 531 | #[tokio::test] | |
| 464 | 532 | async fn updater_check_no_update() { | |
| 465 | 533 | let mut h = harness_with_synckit_storage().await; | |
| @@ -658,7 +726,7 @@ async fn delete_release_cascades() { | |||
| 658 | 726 | .client | |
| 659 | 727 | .post_json( | |
| 660 | 728 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 661 | - | &json!({ "version": "1.0.0", "notes": "", "signature": "" }).to_string(), | |
| 729 | + | &json!({ "version": "1.0.0", "notes": "", "signature": "s" }).to_string(), | |
| 662 | 730 | ) | |
| 663 | 731 | .await; | |
| 664 | 732 | assert_eq!(resp.status, 201); |
| @@ -143,6 +143,40 @@ impl SyncKitClient { | |||
| 143 | 143 | .await | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | + | /// Confirm an uploaded artifact: the server verifies the object landed and | |
| 147 | + | /// enqueues its malware scan. Until the scan clears, the artifact stays | |
| 148 | + | /// `pending` and is neither advertised nor downloadable. Call after | |
| 149 | + | /// [`Self::ota_upload_artifact`]. | |
| 150 | + | #[instrument(skip(self))] | |
| 151 | + | pub async fn ota_confirm_artifact( | |
| 152 | + | &self, | |
| 153 | + | release_id: Uuid, | |
| 154 | + | target: &str, | |
| 155 | + | arch: &str, | |
| 156 | + | ) -> Result<()> { | |
| 157 | + | let token = self.require_token()?; | |
| 158 | + | let url = format!( | |
| 159 | + | "{}/releases/{release_id}/artifacts/confirm", | |
| 160 | + | self.ota_app_base()? | |
| 161 | + | ); | |
| 162 | + | let body = Bytes::from(serde_json::to_vec(&serde_json::json!({ | |
| 163 | + | "target": target, | |
| 164 | + | "arch": arch, | |
| 165 | + | }))?); | |
| 166 | + | ||
| 167 | + | self.retry_request(Idempotency::Safe, || { | |
| 168 | + | let req = self | |
| 169 | + | .http | |
| 170 | + | .post(&url) | |
| 171 | + | .bearer_auth(&token) | |
| 172 | + | .header("content-type", "application/json") | |
| 173 | + | .body(body.clone()); | |
| 174 | + | async move { check_response(req.send().await?).await } | |
| 175 | + | }) | |
| 176 | + | .await?; | |
| 177 | + | Ok(()) | |
| 178 | + | } | |
| 179 | + | ||
| 146 | 180 | /// Upload artifact bytes to S3 via a presigned PUT URL. | |
| 147 | 181 | /// | |
| 148 | 182 | /// The bytes are sent as-is (no encryption — OTA artifacts are public). |