Skip to main content

max / makenotwork

8.7 KB · 293 lines History Blame Raw
1 //! OTA release management: releases, artifacts, and app slug assignment.
2
3 use sqlx::PgPool;
4
5 use super::models::{DbOtaArtifact, DbOtaRelease, DbSyncApp};
6 use super::{OtaArtifactId, OtaReleaseId, SyncAppId};
7 use crate::error::Result;
8
9 // ── App slug ──
10
11 /// Set the URL-friendly slug for a sync app.
12 #[tracing::instrument(skip_all)]
13 pub(crate) async fn set_app_slug(pool: &PgPool, app_id: SyncAppId, slug: &str) -> Result<()> {
14 sqlx::query("UPDATE sync_apps SET slug = $2 WHERE id = $1")
15 .bind(app_id)
16 .bind(slug)
17 .execute(pool)
18 .await?;
19
20 Ok(())
21 }
22
23 /// Look up a sync app by its slug.
24 #[tracing::instrument(skip_all)]
25 pub(crate) async fn get_app_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbSyncApp>> {
26 let app = sqlx::query_as::<_, DbSyncApp>(
27 "SELECT * FROM sync_apps WHERE slug = $1 AND is_active = true",
28 )
29 .bind(slug)
30 .fetch_optional(pool)
31 .await?;
32
33 Ok(app)
34 }
35
36 // ── Releases ──
37
38 /// Create a new OTA release for an app.
39 ///
40 /// The signature is per-artifact (Tauri signs each file independently), so it is
41 /// supplied at artifact-register time via [`create_artifact`], not here. The
42 /// legacy `ota_releases.signature` column is left defaulted and unused.
43 ///
44 /// Returns `Conflict` if a release with the same version already exists for
45 /// this app (enforced by the UNIQUE(app_id, version) constraint in migration
46 /// 033).
47 #[tracing::instrument(skip_all)]
48 pub(crate) async fn create_release(
49 pool: &PgPool,
50 app_id: SyncAppId,
51 version: &str,
52 notes: &str,
53 ) -> Result<DbOtaRelease> {
54 let release = sqlx::query_as::<_, DbOtaRelease>(
55 r"
56 INSERT INTO ota_releases (app_id, version, notes)
57 VALUES ($1, $2, $3)
58 ON CONFLICT (app_id, version) DO NOTHING
59 RETURNING *
60 ",
61 )
62 .bind(app_id)
63 .bind(version)
64 .bind(notes)
65 .fetch_optional(pool)
66 .await?;
67
68 release.ok_or_else(|| {
69 crate::error::AppError::Conflict(format!(
70 "OTA release version {version} already exists for this app"
71 ))
72 })
73 }
74
75 /// List all releases for an app, newest first.
76 #[tracing::instrument(skip_all)]
77 pub(crate) async fn list_releases(pool: &PgPool, app_id: SyncAppId) -> Result<Vec<DbOtaRelease>> {
78 let releases = sqlx::query_as::<_, DbOtaRelease>(
79 "SELECT * FROM ota_releases WHERE app_id = $1 ORDER BY pub_date DESC LIMIT 100",
80 )
81 .bind(app_id)
82 .fetch_all(pool)
83 .await?;
84
85 Ok(releases)
86 }
87
88 /// Get the latest release for an app by semantic version (highest version wins).
89 ///
90 /// Falls back to pub_date ordering if version parts aren't numeric.
91 #[tracing::instrument(skip_all)]
92 pub(crate) async fn get_latest_release(
93 pool: &PgPool,
94 app_id: SyncAppId,
95 ) -> Result<Option<DbOtaRelease>> {
96 let release = sqlx::query_as::<_, DbOtaRelease>(
97 r"
98 SELECT * FROM ota_releases WHERE app_id = $1
99 ORDER BY
100 CASE WHEN split_part(version, '-', 1) ~ '^\d+(\.\d+)*$'
101 THEN (string_to_array(split_part(version, '-', 1), '.'))::int[]
102 ELSE ARRAY[0]
103 END DESC,
104 pub_date DESC
105 LIMIT 1
106 ",
107 )
108 .bind(app_id)
109 .fetch_optional(pool)
110 .await?;
111
112 Ok(release)
113 }
114
115 /// Fetch a single release scoped to its app, a direct indexed `(id, app_id)`
116 /// lookup instead of listing the app's whole release set and scanning it in Rust
117 /// (ultra-fuzz Run 11 Perf SER-1). Returns None if the release doesn't exist or
118 /// doesn't belong to the app.
119 #[tracing::instrument(skip_all)]
120 pub(crate) async fn get_release(
121 pool: &PgPool,
122 app_id: SyncAppId,
123 release_id: OtaReleaseId,
124 ) -> Result<Option<DbOtaRelease>> {
125 let release = sqlx::query_as::<_, DbOtaRelease>(
126 "SELECT * FROM ota_releases WHERE id = $1 AND app_id = $2",
127 )
128 .bind(release_id)
129 .bind(app_id)
130 .fetch_optional(pool)
131 .await?;
132
133 Ok(release)
134 }
135
136 /// Delete a release (cascades to artifacts).
137 #[tracing::instrument(skip_all)]
138 pub(crate) async fn delete_release(pool: &PgPool, release_id: OtaReleaseId) -> Result<bool> {
139 let result = sqlx::query("DELETE FROM ota_releases WHERE id = $1")
140 .bind(release_id)
141 .execute(pool)
142 .await?;
143
144 Ok(result.rows_affected() > 0)
145 }
146
147 /// Get artifact S3 keys for a release, verifying it belongs to the given app.
148 /// Returns None if the release doesn't exist or doesn't belong to the app.
149 #[tracing::instrument(skip_all)]
150 pub(crate) async fn get_release_artifact_keys(
151 pool: &PgPool,
152 app_id: SyncAppId,
153 release_id: OtaReleaseId,
154 ) -> Result<Option<Vec<String>>> {
155 // Verify release belongs to app
156 let exists: bool = sqlx::query_scalar(
157 "SELECT EXISTS(SELECT 1 FROM ota_releases WHERE id = $1 AND app_id = $2)",
158 )
159 .bind(release_id)
160 .bind(app_id)
161 .fetch_one(pool)
162 .await?;
163
164 if !exists {
165 return Ok(None);
166 }
167
168 let keys: Vec<String> =
169 sqlx::query_scalar("SELECT s3_key FROM ota_artifacts WHERE release_id = $1")
170 .bind(release_id)
171 .fetch_all(pool)
172 .await?;
173
174 Ok(Some(keys))
175 }
176
177 // ── Artifacts ──
178
179 /// Create or replace an artifact record for a release.
180 ///
181 /// The S3 key is deterministic (`ota/{app}/{version}/{target}/{arch}/artifact`) and
182 /// the object overwrites in place, so re-uploading the same artifact (fixing a bad
183 /// binary, retrying a half-finished upload) must succeed. Upsert on the
184 /// `(release_id, target, arch)` unique key rather than raising 23505 -> 500 on the
185 /// second upload (ultra-fuzz Run 12 Storage F1).
186 ///
187 /// `signature` is the artifact's own minisign signature and must be non-empty: an
188 /// unsigned artifact can never be installed (the Tauri updater silently refuses
189 /// it), so storing "" just advertises a dead download. Reject it at the write
190 /// boundary. Re-upload preserves an existing signature when a new one isn't
191 /// supplied, so a bytes-only retry doesn't blank it.
192 #[tracing::instrument(skip_all)]
193 pub(crate) async fn create_artifact(
194 pool: &PgPool,
195 release_id: OtaReleaseId,
196 target: &str,
197 arch: &str,
198 s3_key: &str,
199 file_size: i64,
200 signature: &str,
201 ) -> Result<DbOtaArtifact> {
202 if signature.trim().is_empty() {
203 return Err(crate::error::AppError::BadRequest(
204 "OTA artifact signature is required".to_string(),
205 ));
206 }
207 let artifact = sqlx::query_as::<_, DbOtaArtifact>(
208 r"
209 INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size, signature)
210 VALUES ($1, $2, $3, $4, $5, $6)
211 ON CONFLICT (release_id, target, arch)
212 DO UPDATE SET s3_key = EXCLUDED.s3_key, file_size = EXCLUDED.file_size,
213 signature = EXCLUDED.signature, scan_status = 'pending'
214 RETURNING *
215 ",
216 )
217 .bind(release_id)
218 .bind(target)
219 .bind(arch)
220 .bind(s3_key)
221 .bind(file_size)
222 .bind(signature)
223 .fetch_one(pool)
224 .await?;
225
226 Ok(artifact)
227 }
228
229 /// Update an OTA artifact's malware-scan status (called by the scan worker).
230 #[tracing::instrument(skip_all)]
231 pub(crate) async fn update_artifact_scan_status(
232 pool: &PgPool,
233 artifact_id: OtaArtifactId,
234 status: crate::db::FileScanStatus,
235 ) -> std::result::Result<(), sqlx::Error> {
236 sqlx::query("UPDATE ota_artifacts SET scan_status = $1 WHERE id = $2")
237 .bind(status)
238 .bind(artifact_id)
239 .execute(pool)
240 .await?;
241 Ok(())
242 }
243
244 /// Get an artifact by release, target, and arch.
245 #[tracing::instrument(skip_all)]
246 /// One artifact by id. What the scan worker has after it finishes: it is told
247 /// which artifact it scanned, not which release the artifact belongs to.
248 pub(crate) async fn get_artifact_by_id(
249 pool: &PgPool,
250 artifact_id: OtaArtifactId,
251 ) -> Result<Option<DbOtaArtifact>> {
252 let artifact = sqlx::query_as::<_, DbOtaArtifact>("SELECT * FROM ota_artifacts WHERE id = $1")
253 .bind(artifact_id)
254 .fetch_optional(pool)
255 .await?;
256
257 Ok(artifact)
258 }
259
260 /// Every artifact in a release, ordered so a rendering of them is stable
261 /// between two reads.
262 pub(crate) async fn list_artifacts(
263 pool: &PgPool,
264 release_id: OtaReleaseId,
265 ) -> Result<Vec<DbOtaArtifact>> {
266 let artifacts = sqlx::query_as::<_, DbOtaArtifact>(
267 "SELECT * FROM ota_artifacts WHERE release_id = $1 ORDER BY target, arch",
268 )
269 .bind(release_id)
270 .fetch_all(pool)
271 .await?;
272
273 Ok(artifacts)
274 }
275
276 pub(crate) async fn get_artifact(
277 pool: &PgPool,
278 release_id: OtaReleaseId,
279 target: &str,
280 arch: &str,
281 ) -> Result<Option<DbOtaArtifact>> {
282 let artifact = sqlx::query_as::<_, DbOtaArtifact>(
283 "SELECT * FROM ota_artifacts WHERE release_id = $1 AND target = $2 AND arch = $3",
284 )
285 .bind(release_id)
286 .bind(target)
287 .bind(arch)
288 .fetch_optional(pool)
289 .await?;
290
291 Ok(artifact)
292 }
293