1use 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
27pub type ContentId = [u8; 32];
29
30#[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
46pub 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
66pub 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
93pub 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
113pub 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 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}