Skip to main content

max / makenotwork

22.6 KB · 704 lines History Blame Raw
1 //! OTA (Over-The-Air) update endpoints for Tauri-compatible auto-updates.
2 //!
3 //! Management endpoints use SyncKit JWT auth (app owner only).
4 //! Public endpoints (updater check, artifact download) are unauthenticated.
5 //!
6 //! See also: `/docs/developer/ota`
7
8 use axum::{
9 Json,
10 extract::{Path, State},
11 response::IntoResponse,
12 routing::{get, post},
13 };
14 use chrono::{DateTime, Utc};
15 use serde::{Deserialize, Serialize};
16 use tower_governor::GovernorLayer;
17
18 use std::sync::Arc;
19
20 use sqlx::PgPool;
21
22 use crate::{
23 AppState, AppStorage, Scanning,
24 config::Config,
25 constants,
26 csrf::{CsrfRouter, delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip},
27 db::{self, OtaReleaseId, SyncAppId},
28 error::{AppError, Result},
29 scanning::ScanPipeline,
30 synckit_auth::SyncUser,
31 };
32
33 // ── Validation ──
34
35 /// Allowed target operating systems.
36 const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"];
37
38 /// Allowed CPU architectures.
39 const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"];
40
41 /// Validate an app slug: 3-40 chars, lowercase alphanumeric + hyphens,
42 /// no leading/trailing hyphens.
43 ///
44 /// Also exposed as `validate_slug_public` for the session-auth slug endpoint.
45 fn validate_slug(slug: &str) -> Result<()> {
46 if slug.len() < 3 || slug.len() > 40 {
47 return Err(AppError::BadRequest(
48 "Slug must be 3-40 characters".to_string(),
49 ));
50 }
51
52 let bytes = slug.as_bytes();
53 if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' {
54 return Err(AppError::BadRequest(
55 "Slug cannot start or end with a hyphen".to_string(),
56 ));
57 }
58
59 if !slug
60 .chars()
61 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
62 {
63 return Err(AppError::BadRequest(
64 "Slug must contain only lowercase letters, digits, and hyphens".to_string(),
65 ));
66 }
67
68 Ok(())
69 }
70
71 fn validate_target(target: &str) -> Result<()> {
72 if !ALLOWED_TARGETS.contains(&target) {
73 return Err(AppError::BadRequest(format!(
74 "Invalid target '{}'. Allowed: {}",
75 target,
76 ALLOWED_TARGETS.join(", ")
77 )));
78 }
79 Ok(())
80 }
81
82 fn validate_arch(arch: &str) -> Result<()> {
83 if !ALLOWED_ARCHS.contains(&arch) {
84 return Err(AppError::BadRequest(format!(
85 "Invalid arch '{}'. Allowed: {}",
86 arch,
87 ALLOWED_ARCHS.join(", ")
88 )));
89 }
90 Ok(())
91 }
92
93 fn validate_semver(version: &str) -> Result<semver::Version> {
94 semver::Version::parse(version).map_err(|_| {
95 AppError::BadRequest(format!(
96 "Invalid semver version '{version}'. Expected format: X.Y.Z"
97 ))
98 })
99 }
100
101 /// Verify the authenticated user owns the given sync app.
102 async fn verify_app_owner(
103 db: &PgPool,
104 sync_user: &SyncUser,
105 app_id: SyncAppId,
106 ) -> Result<db::DbSyncApp> {
107 let app = db::synckit::get_sync_app_by_id(db, app_id)
108 .await?
109 .ok_or(AppError::NotFound)?;
110
111 if app.creator_id != sync_user.user_id {
112 return Err(AppError::Forbidden);
113 }
114
115 Ok(app)
116 }
117
118 // ── Request/Response types ──
119
120 #[derive(Deserialize)]
121 struct SetSlugRequest {
122 slug: String,
123 }
124
125 #[derive(Deserialize)]
126 struct CreateReleaseRequest {
127 version: String,
128 #[serde(default)]
129 notes: String,
130 }
131
132 #[derive(Serialize)]
133 struct ReleaseResponse {
134 id: OtaReleaseId,
135 version: String,
136 notes: String,
137 pub_date: DateTime<Utc>,
138 created_at: DateTime<Utc>,
139 }
140
141 impl From<db::DbOtaRelease> for ReleaseResponse {
142 fn from(r: db::DbOtaRelease) -> Self {
143 Self {
144 id: r.id,
145 version: r.version,
146 notes: r.notes,
147 pub_date: r.pub_date,
148 created_at: r.created_at,
149 }
150 }
151 }
152
153 #[derive(Deserialize)]
154 struct UploadArtifactRequest {
155 target: String,
156 arch: String,
157 file_size: i64,
158 /// The artifact's minisign signature (per-file: Tauri signs each platform
159 /// independently). Served verbatim to the updater for this target/arch.
160 #[serde(default)]
161 signature: String,
162 }
163
164 #[derive(Serialize)]
165 struct UploadArtifactResponse {
166 upload_url: String,
167 s3_key: String,
168 }
169
170 /// Tauri-compatible updater response (returned when an update is available).
171 #[derive(Serialize)]
172 struct TauriUpdaterResponse {
173 version: String,
174 url: String,
175 signature: String,
176 notes: String,
177 pub_date: String,
178 }
179
180 // ── Management endpoints (SyncKit JWT auth) ──
181
182 /// Set the URL slug for a sync app.
183 ///
184 /// `PUT /api/sync/ota/apps/{app_id}/slug`
185 #[tracing::instrument(skip_all, name = "ota::set_slug")]
186 async fn set_slug(
187 State(db): State<PgPool>,
188 sync_user: SyncUser,
189 Path(app_id): Path<SyncAppId>,
190 Json(req): Json<SetSlugRequest>,
191 ) -> Result<impl IntoResponse> {
192 verify_app_owner(&db, &sync_user, app_id).await?;
193 validate_slug(&req.slug)?;
194
195 db::ota::set_app_slug(&db, app_id, &req.slug).await?;
196
197 Ok(axum::http::StatusCode::NO_CONTENT)
198 }
199
200 /// Create a new OTA release.
201 ///
202 /// `POST /api/sync/ota/apps/{app_id}/releases`
203 #[tracing::instrument(skip_all, name = "ota::create_release")]
204 async fn create_release(
205 State(db): State<PgPool>,
206 sync_user: SyncUser,
207 Path(app_id): Path<SyncAppId>,
208 Json(req): Json<CreateReleaseRequest>,
209 ) -> Result<impl IntoResponse> {
210 verify_app_owner(&db, &sync_user, app_id).await?;
211 validate_semver(&req.version)?;
212
213 // The signature is per-artifact now (Tauri signs each platform's file
214 // independently), so it is supplied when uploading each artifact, not here.
215 let release = db::ota::create_release(&db, app_id, &req.version, &req.notes).await?;
216
217 Ok((
218 axum::http::StatusCode::CREATED,
219 Json(ReleaseResponse::from(release)),
220 ))
221 }
222
223 /// List all releases for an app.
224 ///
225 /// `GET /api/sync/ota/apps/{app_id}/releases`
226 #[tracing::instrument(skip_all, name = "ota::list_releases")]
227 async fn list_releases(
228 State(db): State<PgPool>,
229 sync_user: SyncUser,
230 Path(app_id): Path<SyncAppId>,
231 ) -> Result<impl IntoResponse> {
232 verify_app_owner(&db, &sync_user, app_id).await?;
233
234 let releases = db::ota::list_releases(&db, app_id).await?;
235 let response: Vec<ReleaseResponse> = releases.into_iter().map(ReleaseResponse::from).collect();
236
237 Ok(Json(response))
238 }
239
240 /// Delete a release and its artifacts.
241 ///
242 /// `DELETE /api/sync/ota/apps/{app_id}/releases/{release_id}`
243 #[tracing::instrument(skip_all, name = "ota::delete_release")]
244 async fn delete_release_handler(
245 State(db): State<PgPool>,
246 sync_user: SyncUser,
247 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
248 ) -> Result<impl IntoResponse> {
249 verify_app_owner(&db, &sync_user, app_id).await?;
250
251 // Get artifact S3 keys (also verifies release belongs to this app)
252 let s3_keys = db::ota::get_release_artifact_keys(&db, app_id, release_id)
253 .await?
254 .ok_or(AppError::NotFound)?;
255
256 // Enqueue keys as the sole durable deletion path, BEFORE the CASCADE delete.
257 // Abort on enqueue failure rather than warn-and-proceed: deleting the rows
258 // anyway would orphan every artifact object in the synckit bucket with no
259 // record (ultra-fuzz Run 12 Storage F3). The queue worker is the only
260 // sanctioned S3 deleter, and its is_s3_key_live guard makes the reverse case
261 // (enqueue succeeds, delete fails) safe.
262 let enqueue_keys: Vec<(String, String)> = s3_keys
263 .iter()
264 .map(|k| (k.clone(), "synckit".to_string()))
265 .collect();
266 db::pending_s3_deletions::enqueue_deletions(&db, &enqueue_keys, "delete_release").await?;
267
268 db::ota::delete_release(&db, release_id).await?;
269
270 Ok(axum::http::StatusCode::NO_CONTENT)
271 }
272
273 /// Upload an artifact for a release. Returns a presigned S3 upload URL.
274 ///
275 /// `POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts`
276 #[tracing::instrument(skip_all, name = "ota::upload_artifact")]
277 async fn upload_artifact(
278 State(db): State<PgPool>,
279 State(storage): State<AppStorage>,
280 sync_user: SyncUser,
281 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
282 Json(req): Json<UploadArtifactRequest>,
283 ) -> Result<impl IntoResponse> {
284 // Ownership check (side effect); the app object itself is no longer needed to
285 // build the key now that artifacts land at a random staging key.
286 verify_app_owner(&db, &sync_user, app_id).await?;
287 validate_target(&req.target)?;
288 validate_arch(&req.arch)?;
289
290 if req.file_size <= 0 {
291 return Err(AppError::BadRequest(
292 "file_size must be positive".to_string(),
293 ));
294 }
295
296 // The signature is served verbatim to the Tauri updater, which verifies the
297 // artifact against it with minisign. An empty/implausible signature produces
298 // an artifact the updater advertises but can never install. Reject it here
299 // rather than shipping an un-installable update. A real base64-encoded
300 // minisign signature is well over 40 chars.
301 let signature = req.signature.trim();
302 if signature.len() < 40 {
303 return Err(AppError::BadRequest(
304 "signature is required (base64-encoded minisign signature)".to_string(),
305 ));
306 }
307
308 // Verify the release belongs to this app (scoped lookup, not a full list scan).
309 db::ota::get_release(&db, app_id, release_id)
310 .await?
311 .ok_or(AppError::NotFound)?;
312
313 // Staging key (unserved); the scan worker promotes it to the content key on a
314 // Clean verdict (C1). The artifact ROW stays singleton per
315 // (release, target, arch), `create_artifact` below overwrites its `s3_key`
316 // pointer on re-upload, so the object no longer needs a deterministic name.
317 let s3_key = crate::storage::S3Client::generate_staging_key("artifact.bin");
318
319 let synckit_s3 = storage.require_synckit_s3()?;
320
321 // Track the pending upload so the reaper can clean it up if never uploaded
322 db::pending_uploads::record_pending_upload(&db, sync_user.user_id, &s3_key, "synckit").await?;
323
324 let upload_url = synckit_s3
325 .presign_upload(
326 &s3_key,
327 "application/octet-stream",
328 Some(constants::OTA_PRESIGN_EXPIRY_SECS),
329 None,
330 None,
331 )
332 .await?;
333
334 // Record the artifact in the DB
335 db::ota::create_artifact(
336 &db,
337 release_id,
338 &req.target,
339 &req.arch,
340 &s3_key,
341 req.file_size,
342 signature,
343 )
344 .await?;
345
346 Ok((
347 axum::http::StatusCode::CREATED,
348 Json(UploadArtifactResponse {
349 upload_url,
350 s3_key: s3_key.into_string(),
351 }),
352 ))
353 }
354
355 /// Enqueue (or resolve) the malware scan for an OTA artifact. Only a `clean`
356 /// artifact is ever advertised or downloaded, so this is what un-gates a
357 /// release. With no scanner configured, OTA uploaders are app-owners (trusted),
358 /// so the artifact is marked clean immediately, mirroring the item path's
359 /// disabled-scanner branch.
360 pub(crate) async fn enqueue_ota_artifact_scan(
361 db: &PgPool,
362 scanner: Option<&Arc<ScanPipeline>>,
363 artifact_id: db::OtaArtifactId,
364 s3_key: &str,
365 user_id: db::UserId,
366 file_size: i64,
367 ) -> Result<()> {
368 if scanner.is_none() {
369 db::ota::update_artifact_scan_status(db, artifact_id, db::FileScanStatus::Clean).await?;
370 return Ok(());
371 }
372 db::scan_jobs::enqueue(
373 db,
374 db::scan_jobs::ScanTargetKind::OtaArtifact,
375 *artifact_id.as_uuid(),
376 s3_key,
377 crate::storage::FileType::Download,
378 user_id,
379 file_size,
380 )
381 .await?;
382 Ok(())
383 }
384
385 #[derive(Deserialize)]
386 struct ConfirmArtifactRequest {
387 target: String,
388 arch: String,
389 }
390
391 /// Confirm an uploaded artifact: verify the object landed, then enqueue its scan.
392 ///
393 /// `POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm`
394 ///
395 /// The CLI calls this after the S3 PUT. Until the scan completes clean, the
396 /// artifact stays `pending` and is neither advertised nor downloadable.
397 #[tracing::instrument(skip_all, name = "ota::confirm_artifact")]
398 async fn confirm_artifact(
399 State(db): State<PgPool>,
400 State(storage): State<AppStorage>,
401 State(scanning): State<Scanning>,
402 sync_user: SyncUser,
403 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
404 Json(req): Json<ConfirmArtifactRequest>,
405 ) -> Result<impl IntoResponse> {
406 let _app = verify_app_owner(&db, &sync_user, app_id).await?;
407 validate_target(&req.target)?;
408 validate_arch(&req.arch)?;
409
410 db::ota::get_release(&db, app_id, release_id)
411 .await?
412 .ok_or(AppError::NotFound)?;
413 let artifact = db::ota::get_artifact(&db, release_id, &req.target, &req.arch)
414 .await?
415 .ok_or(AppError::NotFound)?;
416
417 let synckit_s3 = storage.require_synckit_s3()?;
418 let size = synckit_s3
419 .object_size(&artifact.s3_key)
420 .await?
421 .ok_or_else(|| {
422 AppError::BadRequest(
423 "artifact object not found in storage; upload it before confirming".to_string(),
424 )
425 })?;
426
427 enqueue_ota_artifact_scan(
428 &db,
429 scanning.scanner.as_ref(),
430 artifact.id,
431 &artifact.s3_key,
432 sync_user.user_id,
433 size as i64,
434 )
435 .await?;
436
437 Ok(axum::http::StatusCode::ACCEPTED)
438 }
439
440 // ── Public endpoints (no auth) ──
441
442 /// Tauri updater check endpoint.
443 ///
444 /// `GET /api/sync/ota/{slug}/{target}/{arch}/{current_version}`
445 ///
446 /// Returns 200 with Tauri-compatible JSON if a newer version is available,
447 /// or 204 if the client is up to date.
448 #[tracing::instrument(skip_all, name = "ota::updater_check")]
449 async fn updater_check(
450 State(db): State<PgPool>,
451 State(config): State<Config>,
452 Path((slug, target, arch, current_version)): Path<(String, String, String, String)>,
453 ) -> Result<impl IntoResponse> {
454 validate_target(&target)?;
455 validate_arch(&arch)?;
456
457 let current = validate_semver(&current_version)?;
458
459 let app = db::ota::get_app_by_slug(&db, &slug)
460 .await?
461 .ok_or(AppError::NotFound)?;
462
463 let Some(latest) = db::ota::get_latest_release(&db, app.id).await? else {
464 return Ok(axum::http::StatusCode::NO_CONTENT.into_response());
465 };
466
467 let Ok(latest_ver) = semver::Version::parse(&latest.version) else {
468 return Ok(axum::http::StatusCode::NO_CONTENT.into_response());
469 };
470
471 if latest_ver <= current {
472 return Ok(axum::http::StatusCode::NO_CONTENT.into_response());
473 }
474
475 // Check that a scanned-clean artifact exists for this target/arch. A pending
476 // or quarantined artifact is treated as "no update available" (204), never
477 // advertise a binary the scan pipeline hasn't cleared.
478 let Some(artifact) = db::ota::get_artifact(&db, latest.id, &target, &arch).await? else {
479 return Ok(axum::http::StatusCode::NO_CONTENT.into_response());
480 };
481 if artifact.scan_status != db::FileScanStatus::Clean {
482 return Ok(axum::http::StatusCode::NO_CONTENT.into_response());
483 }
484
485 let download_url = format!(
486 "{}/api/sync/ota/{}/download/{}/{}/{}",
487 config.host_url, slug, latest.id, target, arch
488 );
489
490 Ok(Json(TauriUpdaterResponse {
491 version: latest.version,
492 url: download_url,
493 // Per-artifact signature: this platform's file was signed independently,
494 // so serve its own signature, not a shared release-level one.
495 signature: artifact.signature,
496 notes: latest.notes,
497 pub_date: latest.pub_date.to_rfc3339(),
498 })
499 .into_response())
500 }
501
502 /// Artifact download; redirects to a presigned S3 URL.
503 ///
504 /// `GET /api/sync/ota/{slug}/download/{release_id}/{target}/{arch}`
505 #[tracing::instrument(skip_all, name = "ota::artifact_download")]
506 async fn artifact_download(
507 State(db): State<PgPool>,
508 State(storage): State<AppStorage>,
509 Path((slug, release_id, target, arch)): Path<(String, OtaReleaseId, String, String)>,
510 ) -> Result<impl IntoResponse> {
511 validate_target(&target)?;
512 validate_arch(&arch)?;
513
514 // Verify slug resolves to an active app
515 let app = db::ota::get_app_by_slug(&db, &slug)
516 .await?
517 .ok_or(AppError::NotFound)?;
518
519 // Verify release belongs to this app (scoped lookup, not a full list scan)
520 if db::ota::get_release(&db, app.id, release_id)
521 .await?
522 .is_none()
523 {
524 return Err(AppError::NotFound);
525 }
526
527 let artifact = db::ota::get_artifact(&db, release_id, &target, &arch)
528 .await?
529 .ok_or(AppError::NotFound)?;
530
531 // Never hand out a URL to an artifact the scan pipeline hasn't cleared.
532 if artifact.scan_status != db::FileScanStatus::Clean {
533 return Err(AppError::NotFound);
534 }
535
536 let synckit_s3 = storage.require_synckit_s3()?;
537
538 // The artifact row is written at presign time (before the client PUTs the
539 // object), so an abandoned upload leaves a row pointing at an object that never
540 // landed. Verify the object exists before handing out a presigned URL: 404
541 // cleanly here rather than redirecting the updater to a URL that 404s at S3
542 // (ultra-fuzz Run 12 Storage F2).
543 if !synckit_s3.object_exists(&artifact.s3_key).await? {
544 tracing::warn!(%release_id, %target, %arch, "OTA artifact row present but object missing (abandoned upload?)");
545 return Err(AppError::NotFound);
546 }
547
548 let download_url = synckit_s3
549 .presign_download(
550 &crate::storage::S3Key::from_stored(&artifact.s3_key),
551 Some(constants::OTA_PRESIGN_EXPIRY_SECS),
552 )
553 .await?;
554
555 Ok((
556 axum::http::StatusCode::FOUND,
557 [(axum::http::header::LOCATION, download_url)],
558 ))
559 }
560
561 // ── Router ──
562
563 /// Build the OTA route tree.
564 ///
565 /// Management routes use SyncKit JWT auth, rate-limited at write tier.
566 /// Public routes (updater check, download) are unauthenticated, rate-limited at read tier.
567 pub fn ota_routes() -> CsrfRouter<AppState> {
568 let write_rate_limit = crate::helpers::rate_limiter_ms(
569 constants::OTA_WRITE_RATE_LIMIT_MS,
570 constants::OTA_WRITE_RATE_LIMIT_BURST,
571 );
572
573 const OTA_SKIP: &str = "synckit OTA: bearer auth, no session";
574 let mgmt_routes = CsrfRouter::new()
575 .route(
576 "/api/sync/ota/apps/{app_id}/slug",
577 put_csrf_skip(OTA_SKIP, set_slug),
578 )
579 .route(
580 "/api/v1/sync/ota/apps/{app_id}/slug",
581 put_csrf_skip(OTA_SKIP, set_slug),
582 )
583 .route(
584 "/api/sync/ota/apps/{app_id}/releases",
585 with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)),
586 )
587 .route(
588 "/api/v1/sync/ota/apps/{app_id}/releases",
589 with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)),
590 )
591 .route(
592 "/api/sync/ota/apps/{app_id}/releases/{release_id}",
593 delete_csrf_skip(OTA_SKIP, delete_release_handler),
594 )
595 .route(
596 "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}",
597 delete_csrf_skip(OTA_SKIP, delete_release_handler),
598 )
599 .route(
600 "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts",
601 post_csrf_skip(OTA_SKIP, upload_artifact),
602 )
603 .route(
604 "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts",
605 post_csrf_skip(OTA_SKIP, upload_artifact),
606 )
607 .route(
608 "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm",
609 post_csrf_skip(OTA_SKIP, confirm_artifact),
610 )
611 .route(
612 "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm",
613 post_csrf_skip(OTA_SKIP, confirm_artifact),
614 )
615 .route_layer(GovernorLayer::new(write_rate_limit));
616
617 let read_rate_limit = crate::helpers::rate_limiter_ms(
618 constants::OTA_READ_RATE_LIMIT_MS,
619 constants::OTA_READ_RATE_LIMIT_BURST,
620 );
621
622 let public_routes = CsrfRouter::new()
623 .route_get(
624 "/api/sync/ota/{slug}/{target}/{arch}/{current_version}",
625 get(updater_check),
626 )
627 .route_get(
628 "/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}",
629 get(updater_check),
630 )
631 .route_get(
632 "/api/sync/ota/{slug}/download/{release_id}/{target}/{arch}",
633 get(artifact_download),
634 )
635 .route_get(
636 "/api/v1/sync/ota/{slug}/download/{release_id}/{target}/{arch}",
637 get(artifact_download),
638 )
639 .route_layer(GovernorLayer::new(read_rate_limit));
640
641 mgmt_routes.merge(public_routes)
642 }
643
644 /// Public slug validation for use by session-auth endpoints.
645 pub fn validate_slug_public(slug: &str) -> Result<()> {
646 validate_slug(slug)
647 }
648
649 #[cfg(test)]
650 mod tests {
651 use super::*;
652
653 /// The Tauri updater plugin reads exactly these five top-level fields
654 /// out of the manifest JSON. Renaming any of them silently breaks every
655 /// installed app (Tauri logs "failed to deserialize updater response"
656 /// and stays on the old version). Pin the contract.
657 #[test]
658 fn tauri_updater_response_json_shape_is_stable() {
659 let resp = TauriUpdaterResponse {
660 version: "0.4.1".into(),
661 url: "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64".into(),
662 signature: "untrusted comment: signature from minisign\nRWS...==".into(),
663 notes: "Bug fixes".into(),
664 pub_date: "2026-06-01T00:00:00+00:00".into(),
665 };
666 let v: serde_json::Value = serde_json::to_value(&resp).unwrap();
667 // Top-level keys, in the order Tauri's deserializer expects them.
668 let keys: Vec<&str> = v
669 .as_object()
670 .unwrap()
671 .keys()
672 .map(std::string::String::as_str)
673 .collect();
674 assert_eq!(
675 keys,
676 vec!["version", "url", "signature", "notes", "pub_date"],
677 "TauriUpdaterResponse field names/order changed, every installed Tauri app will stop updating",
678 );
679 // Type spot-checks: all strings, no surprise nesting.
680 assert!(v["version"].is_string());
681 assert!(v["url"].is_string());
682 assert!(v["signature"].is_string());
683 assert!(v["notes"].is_string());
684 assert!(v["pub_date"].is_string());
685 }
686
687 #[test]
688 fn tauri_updater_response_signature_is_inline_string() {
689 // Architectural assertion: the signature rides INSIDE the manifest
690 // JSON, not as a separate .sig sidecar file in S3. The launchplan
691 // briefly described it as a sidecar; that was wrong. Locking the
692 // architecture in so it doesn't drift back.
693 let resp = TauriUpdaterResponse {
694 version: "0.4.1".into(),
695 url: "https://example".into(),
696 signature: "RWS=".into(),
697 notes: String::new(),
698 pub_date: "2026-06-01T00:00:00Z".into(),
699 };
700 let json = serde_json::to_string(&resp).unwrap();
701 assert!(json.contains(r#""signature":"RWS=""#));
702 }
703 }
704