max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
12 files changed,
+322 insertions,
-18 deletions
| @@ -271,6 +271,13 @@ | |||
| 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 @@ | |||
| 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 | } |
| @@ -275,12 +275,27 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 |
| @@ -400,6 +400,7 @@ | |||
| 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(); |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 30 | 30 | ItemImage, | |
| 31 | 31 | GalleryImage, | |
| 32 | 32 | ContentInsertion, | |
| 33 | + | OtaArtifact, | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | impl ScanTargetKind { | |
| @@ -42,6 +43,7 @@ | |||
| 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 @@ | |||
| 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 | } |