Skip to main content

walletkit_db/
blobs.rs

1//! Content-addressed blob storage shared across consumer vaults.
2//!
3//! Blobs are stored in a single table (`blob_objects`) keyed by the SHA-256
4//! of `b"worldid:blob" || [kind] || plaintext`. Each consumer passes its own
5//! one-byte `kind` tag so credential payloads, PCP packages, etc. share the
6//! table without colliding by content.
7//!
8//! ### On-disk schema (must remain byte-stable)
9//!
10//! ```sql
11//! CREATE TABLE IF NOT EXISTS blob_objects (
12//!     content_id  BLOB    NOT NULL,
13//!     blob_kind   INTEGER NOT NULL,
14//!     created_at  INTEGER NOT NULL,
15//!     bytes       BLOB    NOT NULL,
16//!     PRIMARY KEY (content_id)
17//! );
18//! ```
19
20use sha2::{Digest, Sha256};
21
22use crate::error::{StoreError, StoreResult};
23use walletkit_sqlite::{params, Connection, DbResult};
24
25const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob";
26
27/// 32-byte content identifier for a stored blob.
28pub type ContentId = [u8; 32];
29
30/// Computes the content id for a blob.
31///
32/// Layout: `SHA-256(b"worldid:blob" || [kind] || plaintext)`. The output is
33/// byte-stable; changes to this function break every existing user database.
34#[must_use]
35pub fn compute_content_id(kind: u8, plaintext: &[u8]) -> ContentId {
36    let mut hasher = Sha256::new();
37    hasher.update(CONTENT_ID_PREFIX);
38    hasher.update([kind]);
39    hasher.update(plaintext);
40    let digest = hasher.finalize();
41    let mut out = [0u8; 32];
42    out.copy_from_slice(&digest);
43    out
44}
45
46/// Creates the `blob_objects` table if it does not exist.
47///
48/// Idempotent. The exact DDL is part of the on-disk format contract;
49/// callers must not alter the schema.
50///
51/// # Errors
52///
53/// Returns a database error if the `CREATE TABLE` statement fails.
54pub fn ensure_schema(conn: &Connection) -> DbResult<()> {
55    conn.execute_batch(
56        "CREATE TABLE IF NOT EXISTS blob_objects (
57            content_id  BLOB    NOT NULL,
58            blob_kind   INTEGER NOT NULL,
59            created_at  INTEGER NOT NULL,
60            bytes       BLOB    NOT NULL,
61            PRIMARY KEY (content_id)
62        );",
63    )
64}
65
66/// Inserts a blob with `INSERT OR IGNORE` semantics.
67///
68/// Returns the content id (deterministic from `kind` + `bytes`); if a row
69/// with that id already exists the call is a no-op and the existing row is
70/// left in place.
71///
72/// # Errors
73///
74/// Returns a [`StoreError`] if `now` overflows `i64` or the insert fails.
75pub fn put(
76    conn: &Connection,
77    kind: u8,
78    bytes: &[u8],
79    now: u64,
80) -> StoreResult<ContentId> {
81    let now_i64 = i64::try_from(now).map_err(|_| {
82        StoreError::InvalidInput(format!("now out of range for i64: {now}"))
83    })?;
84    let cid = compute_content_id(kind, bytes);
85    conn.execute(
86        "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes)
87         VALUES (?1, ?2, ?3, ?4)",
88        params![cid.as_ref(), i64::from(kind), now_i64, bytes],
89    )?;
90    Ok(cid)
91}
92
93/// Fetches blob bytes by content id, if present.
94///
95/// Accepts any byte slice so callers can pass `&ContentId`, a slice read
96/// out of another table column, or a `Vec<u8>` without copying. The slice
97/// must be exactly 32 bytes — non-32-byte input would silently match no row
98/// and is rejected up front.
99///
100/// # Errors
101///
102/// Returns a [`StoreError`] if `cid` is not 32 bytes or the query fails.
103pub fn get(conn: &Connection, cid: &[u8]) -> StoreResult<Option<Vec<u8>>> {
104    check_cid_len(cid)?;
105    let bytes = conn.query_row_optional(
106        "SELECT bytes FROM blob_objects WHERE content_id = ?1",
107        params![cid],
108        |row| Ok(row.column_blob(0)),
109    )?;
110    Ok(bytes)
111}
112
113/// Deletes the blob row with the given content id, if it exists.
114///
115/// Consumers handling status transitions that orphan bytes (e.g. a credential
116/// or PCP becoming unreferenced) call this to GC the row. Same 32-byte
117/// requirement as [`get`].
118///
119/// # Errors
120///
121/// Returns a [`StoreError`] if `cid` is not 32 bytes or the delete fails.
122pub fn delete(conn: &Connection, cid: &[u8]) -> StoreResult<()> {
123    check_cid_len(cid)?;
124    conn.execute(
125        "DELETE FROM blob_objects WHERE content_id = ?1",
126        params![cid],
127    )?;
128    Ok(())
129}
130
131fn check_cid_len(cid: &[u8]) -> StoreResult<()> {
132    if cid.len() == 32 {
133        Ok(())
134    } else {
135        Err(StoreError::InvalidInput(format!(
136            "content_id must be 32 bytes, got {}",
137            cid.len()
138        )))
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::{compute_content_id, delete, ensure_schema, get, put};
145    use crate::StoreError;
146    use walletkit_sqlite::test_utils::init_sqlite;
147    use walletkit_sqlite::{params, Connection};
148
149    #[test]
150    fn test_compute_content_id_byte_stable() {
151        // SHA-256(b"worldid:blob" || [0x01] || b"hello"). Frozen value;
152        // changing this hash means breaking every existing user database.
153        let cid = compute_content_id(1, b"hello");
154        let expected: [u8; 32] = hex::decode(
155            "ed4eba40f11beec64d0607586f09b7529418ef31bf2c46cf9b8b905615f2e7ca",
156        )
157        .expect("decode hex")
158        .try_into()
159        .expect("32 bytes");
160        assert_eq!(cid, expected);
161
162        let cid2 = compute_content_id(2, b"hello");
163        assert_ne!(cid, cid2, "kind tag must affect content id");
164    }
165
166    #[test]
167    fn test_put_get_delete_round_trip() {
168        init_sqlite();
169
170        let conn = Connection::open(std::path::Path::new(":memory:"), false)
171            .expect("open in-memory db");
172        ensure_schema(&conn).expect("ensure schema");
173
174        let cid = put(&conn, 7, b"payload", 1000).expect("put");
175        assert_eq!(
176            hex::encode(cid),
177            "1b108fbc2839877f0df50296ab8db5254efe9bb85864c2fc1ac9285a0f55081d"
178        );
179        assert_eq!(cid, compute_content_id(7, b"payload"));
180
181        let stored = get(&conn, &cid).expect("get").expect("present");
182        assert_eq!(stored.as_slice(), b"payload");
183
184        let duplicate_cid = put(&conn, 7, b"payload", 2000).expect("put duplicate");
185        assert_eq!(duplicate_cid, cid);
186        let row_count = conn
187            .query_row(
188                "SELECT COUNT(*) FROM blob_objects WHERE content_id = ?1",
189                params![cid.as_ref()],
190                |row| Ok(row.column_i64(0)),
191            )
192            .expect("count rows");
193        assert_eq!(row_count, 1);
194
195        delete(&conn, &cid).expect("delete");
196        assert!(get(&conn, &cid).expect("get after delete").is_none());
197    }
198
199    #[test]
200    fn test_invalid_inputs_are_not_database_errors() {
201        init_sqlite();
202
203        let conn = Connection::open(std::path::Path::new(":memory:"), false)
204            .expect("open in-memory db");
205        ensure_schema(&conn).expect("ensure schema");
206
207        assert!(matches!(
208            put(&conn, 7, b"payload", u64::MAX),
209            Err(StoreError::InvalidInput(message))
210                if message == "now out of range for i64: 18446744073709551615"
211        ));
212        assert!(matches!(
213            get(&conn, &[0; 31]),
214            Err(StoreError::InvalidInput(message))
215                if message == "content_id must be 32 bytes, got 31"
216        ));
217    }
218}