1#![allow(dead_code)]
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, bail};
7use sha2::{Digest, Sha256};
8use sqlx::SqliteConnection;
9
10use crate::ids::now;
11use crate::types::BlobInventoryRow;
12
13use super::decode::{ImageFacts, ValidatedImage, validate_image};
14use super::validation::{validate_media_type, validate_sha256};
15
16pub struct StoredBlob {
17 pub sha256: String,
18 pub byte_size: i64,
19 pub facts: ImageFacts,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct StagedBlob {
24 pub sha256: String,
25 pub byte_size: i64,
26 pub created: bool,
27}
28
29pub fn sha256_hex(bytes: &[u8]) -> String {
30 let mut hasher = Sha256::new();
31 hasher.update(bytes);
32 hex::encode(hasher.finalize())
33}
34
35pub fn object_path(blob_dir: &Path, sha256: &str) -> Result<PathBuf> {
36 validate_sha256(sha256)?;
37 Ok(blob_dir.join("objects").join("sha256").join(sha256))
38}
39
40pub async fn store_blob(
41 conn: &mut SqliteConnection,
42 blob_dir: &Path,
43 media_type: &str,
44 bytes: &[u8],
45) -> Result<StoredBlob> {
46 let validated = validate_image(bytes.to_vec(), Some(media_type.to_string())).await?;
47 store_validated_blob(conn, blob_dir, validated).await
48}
49
50pub async fn store_validated_blob(
51 conn: &mut SqliteConnection,
52 blob_dir: &Path,
53 validated: ValidatedImage,
54) -> Result<StoredBlob> {
55 let sha256 = sha256_hex(&validated.bytes);
56 let bytes = validated.bytes;
57 let staged = stage_blob(blob_dir, &sha256, &bytes).await?;
58 if let Err(error) = upsert_inventory_available(
59 conn,
60 &staged.sha256,
61 staged.byte_size,
62 &validated.facts.media_type,
63 )
64 .await
65 {
66 if staged.created {
67 remove_staged_blob_if_unreferenced(conn, blob_dir, &staged.sha256).await;
68 }
69 return Err(error);
70 }
71 Ok(StoredBlob {
72 sha256: staged.sha256,
73 byte_size: staged.byte_size,
74 facts: validated.facts,
75 })
76}
77
78pub async fn stage_blob(blob_dir: &Path, sha256: &str, bytes: &[u8]) -> Result<StagedBlob> {
79 validate_sha256(sha256)?;
80 if sha256_hex(bytes) != sha256 {
81 bail!("error attachment-prepared-hash-mismatch");
82 }
83 let path = object_path(blob_dir, sha256)?;
84 let bytes = bytes.to_vec();
85 let write_path = path.clone();
86 let created =
87 super::blocking::run(move || write_object_atomically(&write_path, &bytes)).await?;
88 let byte_size = fs::metadata(&path)
89 .context("could not inspect attachment object")?
90 .len();
91 let byte_size = i64::try_from(byte_size).context("attachment bytes exceed i64")?;
92 Ok(StagedBlob {
93 sha256: sha256.to_string(),
94 byte_size,
95 created,
96 })
97}
98
99pub async fn remove_staged_blob_if_unreferenced(
100 conn: &mut SqliteConnection,
101 blob_dir: &Path,
102 sha256: &str,
103) {
104 let Ok(mut tx) = crate::db::begin_immediate(conn).await else {
105 return;
106 };
107 let protected = sqlx::query_scalar::<_, bool>(
108 "SELECT
109 EXISTS(SELECT 1 FROM task_attachments WHERE sha256 = ?)
110 OR EXISTS(
111 SELECT 1 FROM changes
112 WHERE server_seq IS NULL AND op_type = 'attachment_add'
113 AND json_extract(payload, '$.sha256') = ?
114 )
115 OR EXISTS(
116 SELECT 1 FROM blob_leases WHERE sha256 = ? AND expires_at > ?
117 )
118 OR EXISTS(
119 SELECT 1 FROM blob_upload_reservations
120 WHERE sha256 = ? AND expires_at > ?
121 )
122 OR EXISTS(SELECT 1 FROM local_shared_capture_pins WHERE sha256 = ?)",
123 )
124 .bind(sha256)
125 .bind(sha256)
126 .bind(sha256)
127 .bind(now())
128 .bind(sha256)
129 .bind(now())
130 .bind(sha256)
131 .fetch_one(&mut *tx)
132 .await;
133 if !matches!(protected, Ok(false)) {
134 let _ = tx.rollback().await;
135 return;
136 }
137 let Ok(path) = object_path(blob_dir, sha256) else {
138 let _ = tx.rollback().await;
139 return;
140 };
141 match fs::remove_file(path) {
142 Ok(()) => {}
143 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
144 Err(_) => {
145 let _ = tx.rollback().await;
146 return;
147 }
148 }
149 if sqlx::query("DELETE FROM blob_inventory WHERE sha256 = ?")
150 .bind(sha256)
151 .execute(&mut *tx)
152 .await
153 .is_err()
154 {
155 let _ = tx.rollback().await;
156 return;
157 }
158 let _ = tx.commit().await;
159}
160
161fn write_object_atomically(path: &Path, bytes: &[u8]) -> Result<bool> {
162 if let Some(parent) = path.parent() {
163 fs::create_dir_all(parent).context("could not create attachment object directory")?;
164 if path.exists() {
165 let existing = fs::read(path).context("could not read attachment object")?;
166 if sha256_hex(&existing) != sha256_hex(bytes) {
167 bail!("error attachment-object-content-mismatch");
168 }
169 return Ok(false);
170 }
171 let mut temp = tempfile::Builder::new()
172 .prefix(".aven-stage-")
173 .tempfile_in(parent)
174 .context("could not create attachment staging file")?;
175 std::io::Write::write_all(&mut temp, bytes)?;
176 temp.as_file().sync_all()?;
177 match temp.persist_noclobber(path) {
178 Ok(_) => {
179 if let Err(error) =
180 fs::File::open(parent).and_then(|directory| directory.sync_all())
181 {
182 let _ = fs::remove_file(path);
183 return Err(error.into());
184 }
185 return Ok(true);
186 }
187 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {
188 let existing = fs::read(path).context("could not read attachment object")?;
189 if sha256_hex(&existing) == sha256_hex(bytes) {
190 return Ok(false);
191 }
192 bail!("error attachment-object-content-mismatch");
193 }
194 Err(error) => return Err(error.error.into()),
195 }
196 }
197 bail!("error attachment-object-path-invalid")
198}
199
200pub async fn upsert_inventory_available(
201 conn: &mut SqliteConnection,
202 sha256: &str,
203 byte_size: i64,
204 media_type: &str,
205) -> Result<()> {
206 validate_sha256(sha256)?;
207 validate_media_type(media_type)?;
208 let existing = blob_inventory_row(conn, sha256).await?;
209 let timestamp = now();
210 if let Some(row) = existing {
211 if row.byte_size != byte_size || row.media_type != media_type {
212 bail!("error blob-inventory-metadata-mismatch");
213 }
214 sqlx::query(
215 "UPDATE blob_inventory
216 SET available = 1, last_verified_at = ?
217 WHERE sha256 = ?",
218 )
219 .bind(×tamp)
220 .bind(sha256)
221 .execute(&mut *conn)
222 .await?;
223 return Ok(());
224 }
225 sqlx::query(
226 "INSERT INTO blob_inventory(sha256, byte_size, media_type, available, first_seen_at, last_verified_at)
227 VALUES (?, ?, ?, 1, ?, ?)",
228 )
229 .bind(sha256)
230 .bind(byte_size)
231 .bind(media_type)
232 .bind(×tamp)
233 .bind(×tamp)
234 .execute(&mut *conn)
235 .await?;
236 Ok(())
237}
238
239pub async fn blob_inventory_row(
240 conn: &mut SqliteConnection,
241 sha256: &str,
242) -> Result<Option<BlobInventoryRow>> {
243 validate_sha256(sha256)?;
244 let row = sqlx::query!(
245 "SELECT sha256, byte_size, media_type, available, first_seen_at, last_verified_at
246 FROM blob_inventory WHERE sha256 = ?",
247 sha256
248 )
249 .fetch_optional(&mut *conn)
250 .await?;
251 Ok(row.map(|row| BlobInventoryRow {
252 sha256: row.sha256.unwrap_or_default(),
253 byte_size: row.byte_size,
254 media_type: row.media_type,
255 available: row.available != 0,
256 first_seen_at: row.first_seen_at,
257 last_verified_at: row.last_verified_at,
258 }))
259}
260
261#[cfg(test)]
262mod tests {
263 use std::io::Cursor;
264 use std::path::PathBuf;
265
266 use image::{DynamicImage, ImageFormat, RgbaImage};
267
268 use crate::db::open_db;
269
270 use super::*;
271
272 fn png_bytes() -> Vec<u8> {
273 let mut bytes = Cursor::new(Vec::new());
274 DynamicImage::ImageRgba8(RgbaImage::new(2, 1))
275 .write_to(&mut bytes, ImageFormat::Png)
276 .unwrap();
277 bytes.into_inner()
278 }
279
280 #[tokio::test]
281 async fn stores_and_retrieves_blob_idempotently() {
282 let temp = tempfile::tempdir().unwrap();
283 let db_path = temp.path().join("test.sqlite");
284 let pool = open_db(&db_path).await.unwrap();
285 let mut conn = pool.acquire().await.unwrap();
286
287 let blob_dir = temp.path().join("blobs");
288
289 let bytes = png_bytes();
290 let stored = store_blob(&mut conn, &blob_dir, "image/png", &bytes)
291 .await
292 .unwrap();
293
294 assert_eq!(stored.sha256, sha256_hex(&bytes));
295 assert_eq!(stored.byte_size, bytes.len() as i64);
296 let repeated = store_blob(&mut conn, &blob_dir, "image/png", &bytes)
297 .await
298 .unwrap();
299 assert_eq!(repeated.sha256, stored.sha256);
300 assert_eq!(repeated.byte_size, stored.byte_size);
301 let count: i64 = sqlx::query_scalar("SELECT count(*) FROM blob_inventory")
302 .fetch_one(&mut *conn)
303 .await
304 .unwrap();
305 assert_eq!(count, 1);
306
307 let obj_path = object_path(&blob_dir, &stored.sha256).unwrap();
308 assert!(obj_path.exists(), "blob file should exist on disk");
309
310 let on_disk = std::fs::read(&obj_path).unwrap();
311 assert_eq!(on_disk, bytes);
312
313 let row = blob_inventory_row(&mut conn, &stored.sha256)
314 .await
315 .unwrap()
316 .expect("inventory row should exist");
317 assert!(row.available);
318 assert_eq!(row.sha256, stored.sha256);
319 assert_eq!(row.byte_size, stored.byte_size);
320 }
321
322 #[tokio::test]
323 async fn rejects_inventory_metadata_mismatch() {
324 let temp = tempfile::tempdir().unwrap();
325 let db_path = temp.path().join("test.sqlite");
326 let pool = open_db(&db_path).await.unwrap();
327 let mut conn = pool.acquire().await.unwrap();
328
329 let sha256 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
330 upsert_inventory_available(&mut conn, sha256, 12, "image/png")
331 .await
332 .unwrap();
333
334 let error = upsert_inventory_available(&mut conn, sha256, 13, "image/png")
335 .await
336 .unwrap_err()
337 .to_string();
338 assert_eq!(error, "error blob-inventory-metadata-mismatch");
339
340 let error = upsert_inventory_available(&mut conn, sha256, 12, "image/jpeg")
341 .await
342 .unwrap_err()
343 .to_string();
344 assert_eq!(error, "error blob-inventory-metadata-mismatch");
345 }
346
347 #[tokio::test]
348 async fn inventory_failure_removes_only_newly_staged_blob() {
349 let temp = tempfile::tempdir().unwrap();
350 let db_path = temp.path().join("test.sqlite");
351 let pool = open_db(&db_path).await.unwrap();
352 let mut conn = pool.acquire().await.unwrap();
353 let blob_dir = temp.path().join("blobs");
354 let bytes = png_bytes();
355 let sha256 = sha256_hex(&bytes);
356 upsert_inventory_available(&mut conn, &sha256, 12, "image/png")
357 .await
358 .unwrap();
359
360 assert!(
361 store_blob(&mut conn, &blob_dir, "image/png", &bytes)
362 .await
363 .is_err()
364 );
365 assert!(!object_path(&blob_dir, &sha256).unwrap().exists());
366 }
367
368 #[test]
369 fn computes_sha256_hex() {
370 assert_eq!(
371 sha256_hex(b"hello"),
372 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
373 );
374 }
375
376 #[test]
377 fn computes_object_path() {
378 let blob_dir = Path::new("/tmp/aven");
379 let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
380 let path = object_path(blob_dir, hash).unwrap();
381 assert_eq!(
382 path,
383 PathBuf::from(
384 "/tmp/aven/objects/sha256/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
385 )
386 );
387 }
388
389 #[test]
390 fn rejects_invalid_hash_in_object_path() {
391 assert!(object_path(Path::new("/tmp"), "short").is_err());
392 }
393}