1use runifold_model::{
2 Artifact, ArtifactError, ArtifactFuture, ArtifactPage, ArtifactRef, ArtifactScope,
3 ArtifactStore, ArtifactWrite, DEFAULT_MAX_ARTIFACT_BYTES, MAX_ARTIFACT_PAGE_SIZE,
4};
5use rusqlite::{OptionalExtension, TransactionBehavior, params};
6use sha2::{Digest, Sha256};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use super::SqliteStore;
10
11impl ArtifactStore for SqliteStore {
12 fn put(&self, write: ArtifactWrite) -> ArtifactFuture<'_, Result<ArtifactRef, ArtifactError>> {
13 let store = self.clone();
14 Box::pin(async move {
15 tokio::task::spawn_blocking(move || store.put_artifact_blocking(&write))
16 .await
17 .map_err(|error| ArtifactError::Storage(error.to_string()))?
18 })
19 }
20
21 fn get(&self, reference: &ArtifactRef) -> ArtifactFuture<'_, Result<Artifact, ArtifactError>> {
22 let store = self.clone();
23 let reference = reference.clone();
24 Box::pin(async move {
25 tokio::task::spawn_blocking(move || store.get_artifact_blocking(&reference))
26 .await
27 .map_err(|error| ArtifactError::Storage(error.to_string()))?
28 })
29 }
30
31 fn list(
32 &self,
33 scope: &ArtifactScope,
34 after: Option<&str>,
35 limit: u32,
36 ) -> ArtifactFuture<'_, Result<ArtifactPage, ArtifactError>> {
37 let store = self.clone();
38 let scope = scope.clone();
39 let after = after.map(str::to_owned);
40 Box::pin(async move {
41 tokio::task::spawn_blocking(move || {
42 store.list_artifacts_blocking(&scope, after.as_deref(), limit)
43 })
44 .await
45 .map_err(|error| ArtifactError::Storage(error.to_string()))?
46 })
47 }
48
49 fn delete(
50 &self,
51 scope: &ArtifactScope,
52 artifact_id: &str,
53 ) -> ArtifactFuture<'_, Result<bool, ArtifactError>> {
54 let store = self.clone();
55 let scope = scope.clone();
56 let artifact_id = artifact_id.to_owned();
57 Box::pin(async move {
58 tokio::task::spawn_blocking(move || {
59 store.delete_artifact_blocking(&scope, &artifact_id)
60 })
61 .await
62 .map_err(|error| ArtifactError::Storage(error.to_string()))?
63 })
64 }
65
66 fn purge_expired(
67 &self,
68 scope: &ArtifactScope,
69 now_unix_ms: u64,
70 limit: u32,
71 ) -> ArtifactFuture<'_, Result<u32, ArtifactError>> {
72 let store = self.clone();
73 let scope = scope.clone();
74 Box::pin(async move {
75 tokio::task::spawn_blocking(move || {
76 store.purge_artifacts_blocking(&scope, now_unix_ms, limit)
77 })
78 .await
79 .map_err(|error| ArtifactError::Storage(error.to_string()))?
80 })
81 }
82}
83
84impl SqliteStore {
85 fn put_artifact_blocking(&self, write: &ArtifactWrite) -> Result<ArtifactRef, ArtifactError> {
86 if write.bytes().len() > DEFAULT_MAX_ARTIFACT_BYTES {
87 return Err(ArtifactError::InvalidInput(format!(
88 "artifact is {} bytes and exceeds the {}-byte limit",
89 write.bytes().len(),
90 DEFAULT_MAX_ARTIFACT_BYTES
91 )));
92 }
93 let digest = sha256(write.bytes());
94 let artifact_id = artifact_identity(write.media_type(), write.bytes());
95 let size_bytes = i64::try_from(write.bytes().len())
96 .map_err(|_| ArtifactError::InvalidInput("artifact size exceeds SQLite i64".into()))?;
97 let created_at_ms = i64_value(unix_time_ms()?, "creation time")?;
98 let expires_at_ms = write
99 .expires_at_unix_ms()
100 .map(|value| i64_value(value, "expiration time"))
101 .transpose()?;
102 let mut connection = self.lock();
103 let transaction = connection
104 .transaction_with_behavior(TransactionBehavior::Immediate)
105 .map_err(|error| storage(&error))?;
106 let existing = transaction
107 .query_row(
108 "SELECT artifact_id FROM runifold_artifact_idempotency
109 WHERE scope = ?1 AND idempotency_key = ?2",
110 params![write.scope().as_str(), write.idempotency_key()],
111 |row| row.get::<_, String>(0),
112 )
113 .optional()
114 .map_err(|error| storage(&error))?;
115 if let Some(existing) = existing {
116 if existing != artifact_id {
117 return Err(ArtifactError::IdempotencyConflict(
118 write.idempotency_key().into(),
119 ));
120 }
121 let reference = load_reference(&transaction, write.scope(), &existing)?;
122 if !write.matches_immutable_reference(&reference) {
123 return Err(ArtifactError::IdempotencyConflict(
124 write.idempotency_key().into(),
125 ));
126 }
127 return Ok(reference);
128 }
129 transaction
130 .execute(
131 "INSERT OR IGNORE INTO runifold_artifacts
132 (scope, artifact_id, media_type, size_bytes, sha256, name, bytes,
133 created_at_ms, expires_at_ms)
134 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
135 params![
136 write.scope().as_str(),
137 artifact_id,
138 write.media_type(),
139 size_bytes,
140 digest,
141 write.name(),
142 write.bytes(),
143 created_at_ms,
144 expires_at_ms
145 ],
146 )
147 .map_err(|error| storage(&error))?;
148 let reference = load_reference(&transaction, write.scope(), &artifact_id)?;
149 if !write.matches_immutable_reference(&reference) {
150 return Err(ArtifactError::MetadataConflict(artifact_id));
151 }
152 transaction
153 .execute(
154 "INSERT INTO runifold_artifact_idempotency (scope, idempotency_key, artifact_id)
155 VALUES (?1, ?2, ?3)",
156 params![write.scope().as_str(), write.idempotency_key(), artifact_id],
157 )
158 .map_err(|error| storage(&error))?;
159 transaction.commit().map_err(|error| storage(&error))?;
160 Ok(reference)
161 }
162
163 fn get_artifact_blocking(&self, expected: &ArtifactRef) -> Result<Artifact, ArtifactError> {
164 let connection = self.lock();
165 let artifact = connection
166 .query_row(
167 "SELECT media_type, size_bytes, sha256, name, bytes,
168 created_at_ms, expires_at_ms
169 FROM runifold_artifacts WHERE scope = ?1 AND artifact_id = ?2",
170 params![expected.scope.as_str(), expected.artifact_id],
171 |row| {
172 Ok(Artifact {
173 reference: ArtifactRef {
174 scope: expected.scope.clone(),
175 artifact_id: expected.artifact_id.clone(),
176 media_type: row.get(0)?,
177 size_bytes: u64::try_from(row.get::<_, i64>(1)?).map_err(|error| {
178 rusqlite::Error::FromSqlConversionFailure(
179 1,
180 rusqlite::types::Type::Integer,
181 Box::new(error),
182 )
183 })?,
184 sha256: row.get(2)?,
185 name: row.get(3)?,
186 created_at_unix_ms: unsigned_i64(row.get(5)?, 5)?,
187 expires_at_unix_ms: row
188 .get::<_, Option<i64>>(6)?
189 .map(|value| unsigned_i64(value, 6))
190 .transpose()?,
191 },
192 bytes: row.get(4)?,
193 })
194 },
195 )
196 .optional()
197 .map_err(|error| storage(&error))?
198 .ok_or_else(|| ArtifactError::NotFound(expected.artifact_id.clone()))?;
199 if artifact.reference.size_bytes != artifact.bytes.len() as u64
200 || artifact.reference.sha256 != sha256(&artifact.bytes)
201 || artifact.reference.artifact_id
202 != artifact_identity(&artifact.reference.media_type, &artifact.bytes)
203 {
204 return Err(ArtifactError::Integrity(expected.artifact_id.clone()));
205 }
206 if artifact.reference != *expected {
207 return Err(ArtifactError::Integrity(expected.artifact_id.clone()));
208 }
209 ensure_not_expired(&artifact.reference, unix_time_ms()?)?;
210 Ok(artifact)
211 }
212
213 fn list_artifacts_blocking(
214 &self,
215 scope: &ArtifactScope,
216 after: Option<&str>,
217 limit: u32,
218 ) -> Result<ArtifactPage, ArtifactError> {
219 validate_limit(limit)?;
220 let fetch = i64::from(limit) + 1;
221 let connection = self.lock();
222 let mut statement = connection
223 .prepare(
224 "SELECT artifact_id, media_type, size_bytes, sha256, name,
225 created_at_ms, expires_at_ms
226 FROM runifold_artifacts
227 WHERE scope = ?1 AND artifact_id > ?2
228 ORDER BY artifact_id LIMIT ?3",
229 )
230 .map_err(|error| storage(&error))?;
231 let rows = statement
232 .query_map(params![scope.as_str(), after.unwrap_or(""), fetch], |row| {
233 Ok(ArtifactRef {
234 scope: scope.clone(),
235 artifact_id: row.get(0)?,
236 media_type: row.get(1)?,
237 size_bytes: unsigned_i64(row.get(2)?, 2)?,
238 sha256: row.get(3)?,
239 name: row.get(4)?,
240 created_at_unix_ms: unsigned_i64(row.get(5)?, 5)?,
241 expires_at_unix_ms: row
242 .get::<_, Option<i64>>(6)?
243 .map(|value| unsigned_i64(value, 6))
244 .transpose()?,
245 })
246 })
247 .map_err(|error| storage(&error))?;
248 let mut items = rows
249 .collect::<Result<Vec<_>, _>>()
250 .map_err(|error| storage(&error))?;
251 let next_cursor = if items.len() > limit as usize {
252 items.pop();
253 items.last().map(|item| item.artifact_id.clone())
254 } else {
255 None
256 };
257 Ok(ArtifactPage { items, next_cursor })
258 }
259
260 fn delete_artifact_blocking(
261 &self,
262 scope: &ArtifactScope,
263 artifact_id: &str,
264 ) -> Result<bool, ArtifactError> {
265 let mut connection = self.lock();
266 let transaction = connection
267 .transaction_with_behavior(TransactionBehavior::Immediate)
268 .map_err(|error| storage(&error))?;
269 transaction
270 .execute(
271 "DELETE FROM runifold_artifact_idempotency
272 WHERE scope = ?1 AND artifact_id = ?2",
273 params![scope.as_str(), artifact_id],
274 )
275 .map_err(|error| storage(&error))?;
276 let removed = transaction
277 .execute(
278 "DELETE FROM runifold_artifacts WHERE scope = ?1 AND artifact_id = ?2",
279 params![scope.as_str(), artifact_id],
280 )
281 .map_err(|error| storage(&error))?
282 > 0;
283 transaction.commit().map_err(|error| storage(&error))?;
284 Ok(removed)
285 }
286
287 fn purge_artifacts_blocking(
288 &self,
289 scope: &ArtifactScope,
290 now_unix_ms: u64,
291 limit: u32,
292 ) -> Result<u32, ArtifactError> {
293 validate_limit(limit)?;
294 let now = i64_value(now_unix_ms, "purge time")?;
295 let mut connection = self.lock();
296 let transaction = connection
297 .transaction_with_behavior(TransactionBehavior::Immediate)
298 .map_err(|error| storage(&error))?;
299 let ids = {
300 let mut statement = transaction
301 .prepare(
302 "SELECT artifact_id FROM runifold_artifacts
303 WHERE scope = ?1 AND expires_at_ms IS NOT NULL AND expires_at_ms <= ?2
304 ORDER BY expires_at_ms, artifact_id LIMIT ?3",
305 )
306 .map_err(|error| storage(&error))?;
307 statement
308 .query_map(params![scope.as_str(), now, i64::from(limit)], |row| {
309 row.get::<_, String>(0)
310 })
311 .map_err(|error| storage(&error))?
312 .collect::<Result<Vec<_>, _>>()
313 .map_err(|error| storage(&error))?
314 };
315 for id in &ids {
316 transaction
317 .execute(
318 "DELETE FROM runifold_artifacts WHERE scope = ?1 AND artifact_id = ?2",
319 params![scope.as_str(), id],
320 )
321 .map_err(|error| storage(&error))?;
322 }
323 transaction.commit().map_err(|error| storage(&error))?;
324 u32::try_from(ids.len())
325 .map_err(|_| ArtifactError::Storage("purge count exceeds u32".into()))
326 }
327}
328
329fn load_reference(
330 transaction: &rusqlite::Transaction<'_>,
331 scope: &ArtifactScope,
332 artifact_id: &str,
333) -> Result<ArtifactRef, ArtifactError> {
334 transaction
335 .query_row(
336 "SELECT media_type, size_bytes, sha256, name, created_at_ms, expires_at_ms
337 FROM runifold_artifacts WHERE scope = ?1 AND artifact_id = ?2",
338 params![scope.as_str(), artifact_id],
339 |row| {
340 Ok(ArtifactRef {
341 scope: scope.clone(),
342 artifact_id: artifact_id.into(),
343 media_type: row.get(0)?,
344 size_bytes: u64::try_from(row.get::<_, i64>(1)?).map_err(|error| {
345 rusqlite::Error::FromSqlConversionFailure(
346 1,
347 rusqlite::types::Type::Integer,
348 Box::new(error),
349 )
350 })?,
351 sha256: row.get(2)?,
352 name: row.get(3)?,
353 created_at_unix_ms: unsigned_i64(row.get(4)?, 4)?,
354 expires_at_unix_ms: row
355 .get::<_, Option<i64>>(5)?
356 .map(|value| unsigned_i64(value, 5))
357 .transpose()?,
358 })
359 },
360 )
361 .map_err(|error| storage(&error))
362}
363
364fn sha256(bytes: &[u8]) -> String {
365 hex_digest(Sha256::digest(bytes))
366}
367
368fn artifact_identity(media_type: &str, bytes: &[u8]) -> String {
369 let mut digest = Sha256::new();
370 digest.update(media_type.as_bytes());
371 digest.update([0]);
372 digest.update(bytes);
373 format!("sha256:{}", hex_digest(digest.finalize()))
374}
375
376fn hex_digest(digest: impl AsRef<[u8]>) -> String {
377 const HEX: &[u8; 16] = b"0123456789abcdef";
378 let bytes = digest.as_ref();
379 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
380 for byte in bytes {
381 output.push(char::from(HEX[usize::from(byte >> 4)]));
382 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
383 }
384 output
385}
386
387fn storage(error: &rusqlite::Error) -> ArtifactError {
388 ArtifactError::Storage(error.to_string())
389}
390
391fn unsigned_i64(value: i64, column: usize) -> rusqlite::Result<u64> {
392 u64::try_from(value).map_err(|error| {
393 rusqlite::Error::FromSqlConversionFailure(
394 column,
395 rusqlite::types::Type::Integer,
396 Box::new(error),
397 )
398 })
399}
400
401fn i64_value(value: u64, label: &str) -> Result<i64, ArtifactError> {
402 i64::try_from(value)
403 .map_err(|_| ArtifactError::InvalidInput(format!("artifact {label} exceeds i64")))
404}
405
406fn unix_time_ms() -> Result<u64, ArtifactError> {
407 let elapsed = SystemTime::now()
408 .duration_since(UNIX_EPOCH)
409 .map_err(|error| ArtifactError::Storage(error.to_string()))?;
410 u64::try_from(elapsed.as_millis())
411 .map_err(|_| ArtifactError::Storage("system time exceeds u64 milliseconds".into()))
412}
413
414fn ensure_not_expired(reference: &ArtifactRef, now: u64) -> Result<(), ArtifactError> {
415 if reference
416 .expires_at_unix_ms
417 .is_some_and(|expires| expires <= now)
418 {
419 return Err(ArtifactError::Expired(reference.artifact_id.clone()));
420 }
421 Ok(())
422}
423
424fn validate_limit(limit: u32) -> Result<(), ArtifactError> {
425 if limit == 0 || limit > MAX_ARTIFACT_PAGE_SIZE {
426 return Err(ArtifactError::InvalidInput(format!(
427 "artifact page limit must be between 1 and {MAX_ARTIFACT_PAGE_SIZE}"
428 )));
429 }
430 Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[tokio::test]
438 async fn sqlite_artifacts_survive_idempotent_replay() {
439 let store = SqliteStore::open_in_memory().unwrap();
440 let scope = ArtifactScope::parse("tenant.test").unwrap();
441 let other_scope = ArtifactScope::parse("tenant.other").unwrap();
442 let png = b"\x89PNG\r\n\x1a\npng";
443 let write =
444 ArtifactWrite::new(scope.clone(), "turn:image", "image/png", png.to_vec()).unwrap();
445 let first = store.put(write.clone()).await.unwrap();
446 assert_eq!(store.put(write).await.unwrap(), first);
447 assert_eq!(store.get(&first).await.unwrap().bytes, png);
448 let changed_replay =
449 ArtifactWrite::new(scope.clone(), "turn:image", "image/png", png.to_vec())
450 .unwrap()
451 .with_expires_at_unix_ms(i64::MAX as u64)
452 .unwrap();
453 assert!(matches!(
454 store.put(changed_replay).await,
455 Err(ArtifactError::IdempotencyConflict(_))
456 ));
457 let changed_alias =
458 ArtifactWrite::new(scope.clone(), "turn:alias", "image/png", png.to_vec())
459 .unwrap()
460 .with_name("different")
461 .unwrap();
462 assert!(matches!(
463 store.put(changed_alias).await,
464 Err(ArtifactError::MetadataConflict(_))
465 ));
466 let isolated = store
467 .put(
468 ArtifactWrite::new(other_scope.clone(), "turn:image", "image/png", png.to_vec())
469 .unwrap(),
470 )
471 .await
472 .unwrap();
473 let expired = store
474 .put(
475 ArtifactWrite::new(
476 scope.clone(),
477 "turn:expired",
478 "text/plain",
479 b"expired".to_vec(),
480 )
481 .unwrap()
482 .with_expires_at_unix_ms(1)
483 .unwrap(),
484 )
485 .await
486 .unwrap();
487 assert!(matches!(
488 store.get(&expired).await,
489 Err(ArtifactError::Expired(_))
490 ));
491 assert_eq!(
492 store
493 .purge_expired(&scope, i64::MAX as u64, 10)
494 .await
495 .unwrap(),
496 1
497 );
498 assert_eq!(
499 store.list(&scope, None, 10).await.unwrap().items.as_slice(),
500 std::slice::from_ref(&first)
501 );
502 assert!(store.delete(&scope, &first.artifact_id).await.unwrap());
503 assert!(!store.delete(&scope, &first.artifact_id).await.unwrap());
504 assert_eq!(store.get(&isolated).await.unwrap().bytes, png);
505 }
506}