Skip to main content

max / makenotwork

Gate OTA artifact serving on a malware scan OTA was the one upload channel that never got the scan-then-serve discipline the item/version channel enforces: artifacts were advertised and downloaded straight after upload, unscanned. Now ota_artifacts carries scan_status (migration 160), a ScanTargetKind::OtaArtifact drives the pipeline (the scan worker gained a SyncKit-bucket backend, since OTA objects live there), and updater_check + artifact_download only serve 'clean' artifacts. A new confirm endpoint HEAD- verifies the object and enqueues the scan; the CLI calls it after upload and build_runner enqueues in-process. create_release now refuses an empty signature, and build_runner fails a build that produced no signed artifact instead of publishing a dead release. Regression: updater_check_gated_until_artifact_clean (pending -> 204, clean -> 200). Shared synckit-client gains ota_confirm_artifact.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 18:13 UTC
Signed with PGP, not checked
Commit: 28927b49cfa51bc00ae11d51129e56c95ba9fc9c
Parent: d2d472c
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 }