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() {
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!(stored.byte_size > 0);
296
297 let obj_path = object_path(&blob_dir, &stored.sha256).unwrap();
298 assert!(obj_path.exists(), "blob file should exist on disk");
299
300 let on_disk = std::fs::read(&obj_path).unwrap();
301 assert_eq!(on_disk, bytes);
302
303 let row = blob_inventory_row(&mut conn, &stored.sha256)
304 .await
305 .unwrap()
306 .expect("inventory row should exist");
307 assert!(row.available);
308 assert_eq!(row.sha256, stored.sha256);
309 assert_eq!(row.byte_size, stored.byte_size);
310 }
311
312 #[tokio::test]
313 async fn stores_blob_idempotently() {
314 let temp = tempfile::tempdir().unwrap();
315 let db_path = temp.path().join("test.sqlite");
316 let pool = open_db(&db_path).await.unwrap();
317 let mut conn = pool.acquire().await.unwrap();
318
319 let blob_dir = temp.path().join("blobs");
320
321 let bytes = png_bytes();
322 let stored1 = store_blob(&mut conn, &blob_dir, "image/png", &bytes)
323 .await
324 .unwrap();
325 let stored2 = store_blob(&mut conn, &blob_dir, "image/png", &bytes)
326 .await
327 .unwrap();
328
329 assert_eq!(stored1.sha256, stored2.sha256);
330 let row = blob_inventory_row(&mut conn, &stored1.sha256)
331 .await
332 .unwrap()
333 .unwrap();
334 assert!(row.available);
335 }
336
337 #[tokio::test]
338 async fn rejects_inventory_metadata_mismatch() {
339 let temp = tempfile::tempdir().unwrap();
340 let db_path = temp.path().join("test.sqlite");
341 let pool = open_db(&db_path).await.unwrap();
342 let mut conn = pool.acquire().await.unwrap();
343
344 let sha256 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
345 upsert_inventory_available(&mut conn, sha256, 12, "image/png")
346 .await
347 .unwrap();
348
349 let error = upsert_inventory_available(&mut conn, sha256, 13, "image/png")
350 .await
351 .unwrap_err()
352 .to_string();
353 assert_eq!(error, "error blob-inventory-metadata-mismatch");
354
355 let error = upsert_inventory_available(&mut conn, sha256, 12, "image/jpeg")
356 .await
357 .unwrap_err()
358 .to_string();
359 assert_eq!(error, "error blob-inventory-metadata-mismatch");
360 }
361
362 #[tokio::test]
363 async fn inventory_failure_removes_only_newly_staged_blob() {
364 let temp = tempfile::tempdir().unwrap();
365 let db_path = temp.path().join("test.sqlite");
366 let pool = open_db(&db_path).await.unwrap();
367 let mut conn = pool.acquire().await.unwrap();
368 let blob_dir = temp.path().join("blobs");
369 let bytes = png_bytes();
370 let sha256 = sha256_hex(&bytes);
371 upsert_inventory_available(&mut conn, &sha256, 12, "image/png")
372 .await
373 .unwrap();
374
375 assert!(
376 store_blob(&mut conn, &blob_dir, "image/png", &bytes)
377 .await
378 .is_err()
379 );
380 assert!(!object_path(&blob_dir, &sha256).unwrap().exists());
381 }
382
383 #[test]
384 fn computes_sha256_hex() {
385 let result = sha256_hex(b"hello");
386 assert_eq!(result.len(), 64);
387 assert!(result.bytes().all(|b| b.is_ascii_hexdigit()));
388 }
389
390 #[test]
391 fn computes_object_path() {
392 let blob_dir = Path::new("/tmp/aven");
393 let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
394 let path = object_path(blob_dir, hash).unwrap();
395 assert_eq!(
396 path,
397 PathBuf::from(
398 "/tmp/aven/objects/sha256/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
399 )
400 );
401 }
402
403 #[test]
404 fn rejects_invalid_hash_in_object_path() {
405 assert!(object_path(Path::new("/tmp"), "short").is_err());
406 }
407}