Skip to main content

runifold_store_sqlite/store/
artifact.rs

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}