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
Commit: 28927b49cfa51bc00ae11d51129e56c95ba9fc9c
Parent: d2d472c
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).