1use std::path::PathBuf;
27
28use gwk_domain::blob::{BLOB_CHUNK_BYTES, BlobAddress, BlobDescriptor};
29use gwk_domain::ids::{BlobUploadId, ByteCount, EvidenceId, Timestamp};
30use gwk_domain::port::{BlobError, BlobStore};
31use secrecy::ExposeSecret;
32use sha2::{Digest, Sha256};
33use sqlx::{PgPool, Row};
34use tokio::fs;
35use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
36use zeroize::Zeroize;
37
38use crate::blob::container::{
39 self, CHUNK_LEN_BYTES, DEK_BYTES, MAX_CIPHERTEXT_CHUNK_BYTES, WRAP_NONCE_BYTES,
40 WRAPPED_DEK_BYTES,
41};
42use crate::config::BlobConfig;
43
44pub const UPLOAD_EXPIRY_SECS: i64 = 3600;
48
49const UPLOAD_ID_HEX_LEN: usize = 32;
51
52macro_rules! blob_columns {
59 () => {
60 "b.digest, b.media_type, b.byte_size, b.kek_id, b.wrap_nonce, b.wrapped_dek, \
61 to_json(b.created_at) #>> '{}' AS created_at, \
62 b.tombstoned_at IS NOT NULL AS tombstoned, \
63 EXISTS (SELECT 1 FROM gwk_internal.blob_pin p WHERE p.digest = b.digest) AS pinned"
64 };
65}
66
67macro_rules! unreferenced {
84 () => {
85 "SELECT b.digest FROM gwk_internal.blob b \
86 WHERE b.tombstoned_at IS NULL \
87 AND NOT EXISTS (SELECT 1 FROM gwk_internal.blob_pin p WHERE p.digest = b.digest) \
88 AND NOT EXISTS (SELECT 1 FROM gwk.event e \
89 WHERE e.payload_ref ->> 'digest' = 'sha256:' || b.digest) \
90 AND NOT EXISTS (SELECT 1 FROM gwk_internal.checkpoint c \
91 WHERE c.records_ref ->> 'digest' = 'sha256:' || b.digest)"
92 };
93}
94
95fn storage(context: &str, error: impl std::fmt::Display) -> BlobError {
96 BlobError::Storage(format!("{context}: {error}"))
97}
98
99fn integrity(reason: impl Into<String>) -> BlobError {
100 BlobError::Integrity(reason.into())
101}
102
103fn frame_error(context: &str, error: std::io::Error) -> BlobError {
109 if error.kind() == std::io::ErrorKind::UnexpectedEof {
110 return integrity(format!(
111 "{context}: the container ends before the chunk its header declares"
112 ));
113 }
114 storage(context, error)
115}
116
117pub fn is_upload_id(value: &str) -> bool {
121 value.len() == UPLOAD_ID_HEX_LEN
122 && value
123 .bytes()
124 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
125}
126
127struct BlobRow {
129 descriptor: BlobDescriptor,
130 wrap_nonce: Option<Vec<u8>>,
131 wrapped_dek: Option<Vec<u8>>,
132}
133
134pub struct PgBlobStore {
137 pool: PgPool,
138 config: BlobConfig,
139}
140
141impl PgBlobStore {
142 pub async fn open(pool: PgPool, config: BlobConfig) -> Result<Self, BlobError> {
148 let store = Self { pool, config };
149 for dir in [store.blob_dir(), store.upload_dir()] {
150 fs::create_dir_all(&dir)
151 .await
152 .map_err(|e| storage(&format!("create {}", dir.display()), e))?;
153 }
154 store.expire_uploads().await?;
155 Ok(store)
156 }
157
158 pub fn config(&self) -> &BlobConfig {
159 &self.config
160 }
161
162 fn blob_dir(&self) -> PathBuf {
163 self.config.root().join("blobs")
164 }
165
166 fn upload_dir(&self) -> PathBuf {
167 self.config.root().join("uploads")
168 }
169
170 fn container_path(&self, digest: &str) -> PathBuf {
174 self.blob_dir()
175 .join(&digest[0..2])
176 .join(&digest[2..4])
177 .join(digest)
178 }
179
180 fn upload_path(&self, upload_id: &str) -> PathBuf {
181 self.upload_dir().join(upload_id)
182 }
183
184 fn staging_path(&self, upload_id: &str) -> PathBuf {
186 self.upload_dir().join(format!("{upload_id}.container"))
187 }
188
189 fn upload_id<'a>(&self, upload: &'a BlobUploadId) -> Result<&'a str, BlobError> {
193 let id = upload.as_str();
194 if !is_upload_id(id) {
195 return Err(BlobError::NotFound);
196 }
197 Ok(id)
198 }
199
200 pub async fn expire_uploads(&self) -> Result<(), BlobError> {
207 let stale: Vec<String> = sqlx::query_scalar(
208 "DELETE FROM gwk_internal.blob_upload \
209 WHERE started_at < now() - make_interval(secs => $1::double precision) \
210 RETURNING upload_id",
211 )
212 .bind(UPLOAD_EXPIRY_SECS as f64)
213 .fetch_all(&self.pool)
214 .await
215 .map_err(|e| storage("expire uploads", e))?;
216 for id in stale {
217 self.discard_upload_files(&id).await;
218 }
219 Ok(())
220 }
221
222 async fn discard_upload_files(&self, upload_id: &str) {
227 let _ = fs::remove_file(self.upload_path(upload_id)).await;
228 let _ = fs::remove_file(self.staging_path(upload_id)).await;
229 }
230
231 async fn row(&self, digest: &str) -> Result<Option<BlobRow>, BlobError> {
233 let Some(row) = sqlx::query(concat!(
234 "SELECT ",
235 blob_columns!(),
236 " FROM gwk_internal.blob b WHERE b.digest = $1"
237 ))
238 .bind(digest)
239 .fetch_optional(&self.pool)
240 .await
241 .map_err(|e| storage("read blob row", e))?
242 else {
243 return Ok(None);
244 };
245 let get = |name: &str| -> Result<String, BlobError> {
246 row.try_get(name)
247 .map_err(|e| storage(&format!("column {name}"), e))
248 };
249 let byte_size: i64 = row
250 .try_get("byte_size")
251 .map_err(|e| storage("column byte_size", e))?;
252 Ok(Some(BlobRow {
253 descriptor: BlobDescriptor {
254 address: BlobAddress::from_digest(&get("digest")?)
255 .map_err(|e| storage("column digest", e))?,
256 media_type: get("media_type")?,
257 byte_size: ByteCount::new(
258 u64::try_from(byte_size).map_err(|e| storage("column byte_size", e))?,
259 ),
260 kek_id: get("kek_id")?,
261 created_at: Timestamp::new(get("created_at")?),
262 pinned: row
263 .try_get("pinned")
264 .map_err(|e| storage("column pinned", e))?,
265 tombstoned: row
266 .try_get("tombstoned")
267 .map_err(|e| storage("column tombstoned", e))?,
268 },
269 wrap_nonce: row
270 .try_get("wrap_nonce")
271 .map_err(|e| storage("column wrap_nonce", e))?,
272 wrapped_dek: row
273 .try_get("wrapped_dek")
274 .map_err(|e| storage("column wrapped_dek", e))?,
275 }))
276 }
277
278 async fn readable(&self, address: &BlobAddress) -> Result<BlobRow, BlobError> {
280 let row = self
281 .row(address.digest_hex())
282 .await?
283 .ok_or(BlobError::NotFound)?;
284 if row.descriptor.tombstoned {
285 return Err(BlobError::Tombstoned);
286 }
287 Ok(row)
288 }
289
290 pub async fn rewrap_all(&self, new_kek: &[u8; DEK_BYTES]) -> Result<usize, BlobError> {
297 let digests: Vec<String> = sqlx::query_scalar(
298 "SELECT digest FROM gwk_internal.blob \
299 WHERE kek_id = $1 AND tombstoned_at IS NULL ORDER BY digest",
300 )
301 .bind(self.config.kek_id())
302 .fetch_all(&self.pool)
303 .await
304 .map_err(|e| storage("list blobs to rewrap", e))?;
305
306 let mut rewrapped = 0;
307 for digest in &digests {
308 let row = self.row(digest).await?.ok_or(BlobError::NotFound)?;
309 let (wrap_nonce, wrapped_dek) = key_material(&row)?;
310 let (_, _, header) = self.open_container(&row.descriptor).await?;
311 let new_nonce = container::generate::<aead::consts::U24>()?;
312 let new_wrapped = container::rewrap(
313 &header,
314 self.config.kek().expose_secret(),
315 new_kek,
316 &wrap_nonce,
317 &wrapped_dek,
318 &new_nonce.0,
319 )?;
320 sqlx::query(
324 "UPDATE gwk_internal.blob SET wrap_nonce = $2, wrapped_dek = $3 \
325 WHERE digest = $1 AND tombstoned_at IS NULL",
326 )
327 .bind(digest)
328 .bind(new_nonce.0.as_slice())
329 .bind(new_wrapped.as_slice())
330 .execute(&self.pool)
331 .await
332 .map_err(|e| storage("store rewrapped key", e))?;
333 rewrapped += 1;
334 }
335 Ok(rewrapped)
336 }
337
338 async fn open_container(
347 &self,
348 descriptor: &BlobDescriptor,
349 ) -> Result<(fs::File, container::Header, Vec<u8>), BlobError> {
350 let path = self.container_path(descriptor.address.digest_hex());
351 let mut file = fs::File::open(&path)
352 .await
353 .map_err(|e| storage(&format!("open {}", path.display()), e))?;
354 let expected = container::header_len(&descriptor.media_type, &descriptor.kek_id);
355 let mut head = vec![0u8; expected];
356 file.read_exact(&mut head)
357 .await
358 .map_err(|e| frame_error("read container header", e))?;
359 let (header, actual) = container::Header::decode(&head)?;
364 if actual != expected {
365 return Err(integrity(format!(
366 "container header is {actual} bytes, its row describes {expected}"
367 )));
368 }
369 Ok((file, header, head))
370 }
371}
372
373fn key_material(
375 row: &BlobRow,
376) -> Result<([u8; WRAP_NONCE_BYTES], [u8; WRAPPED_DEK_BYTES]), BlobError> {
377 let (Some(nonce), Some(wrapped)) = (row.wrap_nonce.as_ref(), row.wrapped_dek.as_ref()) else {
380 return Err(BlobError::Tombstoned);
381 };
382 let nonce: [u8; WRAP_NONCE_BYTES] = nonce
383 .as_slice()
384 .try_into()
385 .map_err(|_| integrity("stored wrap nonce is the wrong length"))?;
386 let wrapped: [u8; WRAPPED_DEK_BYTES] = wrapped
387 .as_slice()
388 .try_into()
389 .map_err(|_| integrity("stored wrapped key is the wrong length"))?;
390 Ok((nonce, wrapped))
391}
392
393impl BlobStore for PgBlobStore {
394 async fn begin(
395 &self,
396 media_type: String,
397 byte_size: ByteCount,
398 ) -> Result<BlobUploadId, BlobError> {
399 if media_type.is_empty() || media_type.len() > u16::MAX as usize {
400 return Err(integrity(format!(
401 "media type must be 1..={} bytes",
402 u16::MAX
403 )));
404 }
405 let declared = i64::try_from(byte_size.value())
406 .map_err(|_| integrity("declared size does not fit a signed 64-bit column"))?;
407 self.expire_uploads().await?;
411
412 let id = container::hex_lower(&container::generate::<aead::consts::U16>()?.0);
413 sqlx::query(
417 "INSERT INTO gwk_internal.blob_upload (upload_id, media_type, byte_size) \
418 VALUES ($1, $2, $3)",
419 )
420 .bind(&id)
421 .bind(&media_type)
422 .bind(declared)
423 .execute(&self.pool)
424 .await
425 .map_err(|e| storage("begin upload", e))?;
426 fs::File::create(self.upload_path(&id))
427 .await
428 .map_err(|e| storage("create staging file", e))?;
429 Ok(BlobUploadId::new(id))
430 }
431
432 async fn write_chunk(
433 &self,
434 upload: &BlobUploadId,
435 sequence: u32,
436 chunk: &[u8],
437 ) -> Result<(), BlobError> {
438 let id = self.upload_id(upload)?;
439 let row = sqlx::query(
440 "SELECT byte_size, written, next_chunk, \
441 started_at < now() - make_interval(secs => $2::double precision) AS expired \
442 FROM gwk_internal.blob_upload WHERE upload_id = $1",
443 )
444 .bind(id)
445 .bind(UPLOAD_EXPIRY_SECS as f64)
446 .fetch_optional(&self.pool)
447 .await
448 .map_err(|e| storage("read upload", e))?
449 .ok_or(BlobError::NotFound)?;
450 let expired: bool = row
451 .try_get("expired")
452 .map_err(|e| storage("column expired", e))?;
453 if expired {
454 return Err(BlobError::NotFound);
455 }
456 let declared: i64 = row
457 .try_get("byte_size")
458 .map_err(|e| storage("column byte_size", e))?;
459 let written: i64 = row
460 .try_get("written")
461 .map_err(|e| storage("column written", e))?;
462 let next_chunk: i64 = row
463 .try_get("next_chunk")
464 .map_err(|e| storage("column next_chunk", e))?;
465
466 if i64::from(sequence) != next_chunk {
467 return Err(integrity(format!(
468 "chunk {sequence} is out of order: expected {next_chunk}"
469 )));
470 }
471 let len = i64::try_from(chunk.len())
472 .map_err(|_| integrity("chunk does not fit a signed 64-bit count"))?;
473 let after = written
474 .checked_add(len)
475 .ok_or_else(|| integrity("upload size overflowed"))?;
476 if after > declared {
479 return Err(integrity(format!(
480 "chunk {sequence} would bring the upload to {after} bytes, past the {declared} \
481 it declared"
482 )));
483 }
484
485 let path = self.upload_path(id);
489 let mut file = fs::OpenOptions::new()
490 .write(true)
491 .open(&path)
492 .await
493 .map_err(|e| storage(&format!("open {}", path.display()), e))?;
494 file.seek(std::io::SeekFrom::Start(written as u64))
495 .await
496 .map_err(|e| storage("seek staging file", e))?;
497 file.write_all(chunk)
498 .await
499 .map_err(|e| storage("write chunk", e))?;
500 file.flush().await.map_err(|e| storage("flush chunk", e))?;
501
502 sqlx::query(
503 "UPDATE gwk_internal.blob_upload SET written = $2, next_chunk = next_chunk + 1 \
504 WHERE upload_id = $1",
505 )
506 .bind(id)
507 .bind(after)
508 .execute(&self.pool)
509 .await
510 .map_err(|e| storage("record chunk", e))?;
511 Ok(())
512 }
513
514 async fn commit(
515 &self,
516 upload: BlobUploadId,
517 address: BlobAddress,
518 ) -> Result<(BlobDescriptor, bool), BlobError> {
519 let id = self.upload_id(&upload)?.to_owned();
520 let row = sqlx::query(
521 "SELECT media_type, byte_size, written, \
522 started_at < now() - make_interval(secs => $2::double precision) AS expired \
523 FROM gwk_internal.blob_upload WHERE upload_id = $1",
524 )
525 .bind(&id)
526 .bind(UPLOAD_EXPIRY_SECS as f64)
527 .fetch_optional(&self.pool)
528 .await
529 .map_err(|e| storage("read upload", e))?
530 .ok_or(BlobError::NotFound)?;
531 if row
532 .try_get::<bool, _>("expired")
533 .map_err(|e| storage("column expired", e))?
534 {
535 return Err(BlobError::NotFound);
536 }
537 let media_type: String = row
538 .try_get("media_type")
539 .map_err(|e| storage("column media_type", e))?;
540 let declared: i64 = row
541 .try_get("byte_size")
542 .map_err(|e| storage("column byte_size", e))?;
543 let written: i64 = row
544 .try_get("written")
545 .map_err(|e| storage("column written", e))?;
546 if written != declared {
547 return Err(integrity(format!(
548 "upload holds {written} of the {declared} bytes it declared"
549 )));
550 }
551
552 let mut plaintext = vec![0u8; written as usize];
563 let staged = self.upload_path(&id);
564 fs::File::open(&staged)
565 .await
566 .map_err(|e| storage(&format!("open {}", staged.display()), e))?
567 .read_exact(&mut plaintext)
568 .await
569 .map_err(|e| storage("read staged plaintext", e))?;
570
571 let raw: [u8; 32] = Sha256::digest(&plaintext).into();
572 let digest = container::hex_lower(&raw);
573 if digest != address.digest_hex() {
574 return Err(BlobError::DigestMismatch {
575 expected: address,
576 actual: BlobAddress::from_digest(&digest)
577 .map_err(|e| storage("computed digest", e))?,
578 });
579 }
580
581 if let Some(existing) = self.row(&digest).await? {
582 self.discard_upload_files(&id).await;
583 sqlx::query("DELETE FROM gwk_internal.blob_upload WHERE upload_id = $1")
584 .bind(&id)
585 .execute(&self.pool)
586 .await
587 .map_err(|e| storage("close upload", e))?;
588 if existing.descriptor.tombstoned {
593 return Err(BlobError::Tombstoned);
594 }
595 if existing.descriptor.media_type != media_type {
601 return Err(integrity(format!(
602 "{address} is already stored as {:?}; this upload declares {media_type:?}",
603 existing.descriptor.media_type
604 )));
605 }
606 return Ok((existing.descriptor, true));
607 }
608
609 let mut sealed = container::seal(
610 &plaintext,
611 &media_type,
612 self.config.kek().expose_secret(),
613 self.config.kek_id(),
614 )?;
615 plaintext.zeroize();
616
617 let staging = self.staging_path(&id);
622 fs::write(&staging, &sealed.container)
623 .await
624 .map_err(|e| storage("write container", e))?;
625 let path = self.container_path(&digest);
626 if let Some(parent) = path.parent() {
627 fs::create_dir_all(parent)
628 .await
629 .map_err(|e| storage("create shard directory", e))?;
630 }
631 fs::rename(&staging, &path)
632 .await
633 .map_err(|e| storage("publish container", e))?;
634
635 let inserted: Option<String> = sqlx::query_scalar(
638 "INSERT INTO gwk_internal.blob \
639 (digest, media_type, byte_size, kek_id, wrap_nonce, wrapped_dek) \
640 VALUES ($1, $2, $3, $4, $5, $6) \
641 ON CONFLICT (digest) DO NOTHING \
642 RETURNING digest",
643 )
644 .bind(&digest)
645 .bind(&media_type)
646 .bind(declared)
647 .bind(self.config.kek_id())
648 .bind(sealed.wrap_nonce.as_slice())
649 .bind(sealed.wrapped_dek.as_slice())
650 .fetch_optional(&self.pool)
651 .await
652 .map_err(|e| storage("record blob", e))?;
653 sealed.wrapped_dek.zeroize();
654
655 sqlx::query("DELETE FROM gwk_internal.blob_upload WHERE upload_id = $1")
656 .bind(&id)
657 .execute(&self.pool)
658 .await
659 .map_err(|e| storage("close upload", e))?;
660 self.discard_upload_files(&id).await;
661
662 let row = self.row(&digest).await?.ok_or(BlobError::NotFound)?;
663 Ok((row.descriptor, inserted.is_none()))
668 }
669
670 async fn abort(&self, upload: BlobUploadId) -> Result<(), BlobError> {
671 let id = self.upload_id(&upload)?;
672 let deleted = sqlx::query("DELETE FROM gwk_internal.blob_upload WHERE upload_id = $1")
673 .bind(id)
674 .execute(&self.pool)
675 .await
676 .map_err(|e| storage("abort upload", e))?;
677 self.discard_upload_files(id).await;
678 if deleted.rows_affected() == 0 {
679 return Err(BlobError::NotFound);
680 }
681 Ok(())
682 }
683
684 async fn read(
685 &self,
686 address: &BlobAddress,
687 offset: ByteCount,
688 length: ByteCount,
689 ) -> Result<Vec<u8>, BlobError> {
690 let row = self.readable(address).await?;
691 let (wrap_nonce, wrapped_dek) = key_material(&row)?;
692 let size = row.descriptor.byte_size.value();
693 let offset = offset.value();
694 if offset >= size {
695 return Ok(Vec::new());
696 }
697 let length = length
702 .value()
703 .min(BLOB_CHUNK_BYTES as u64)
704 .min(size - offset);
705 if length == 0 {
706 return Ok(Vec::new());
707 }
708
709 let (mut file, header, aad) = self.open_container(&row.descriptor).await?;
710 let header_len = aad.len();
711 let aad = aad.as_slice();
712 if header.byte_size != size {
716 return Err(integrity(format!(
717 "container declares {} bytes, its row says {size}",
718 header.byte_size
719 )));
720 }
721 let mut dek = container::unwrap_dek(
722 &wrapped_dek,
723 self.config.kek().expose_secret(),
724 &wrap_nonce,
725 aad,
726 )?;
727
728 let chunk = BLOB_CHUNK_BYTES as u64;
729 let final_index = container::chunk_count(size) - 1;
730 let first = offset / chunk;
731 let last = (offset + length - 1) / chunk;
732 let mut plaintext = Vec::with_capacity((length + chunk) as usize);
733 let mut framed = vec![0u8; CHUNK_LEN_BYTES + MAX_CIPHERTEXT_CHUNK_BYTES];
734 for index in first..=last {
735 file.seek(std::io::SeekFrom::Start(container::chunk_offset(
736 header_len, index,
737 )))
738 .await
739 .map_err(|e| storage("seek chunk", e))?;
740 file.read_exact(&mut framed[..CHUNK_LEN_BYTES])
741 .await
742 .map_err(|e| frame_error("read chunk length", e))?;
743 let ciphertext_len = u32::from_be_bytes(
744 framed[..CHUNK_LEN_BYTES]
745 .try_into()
746 .map_err(|_| integrity("chunk length"))?,
747 ) as usize;
748 if ciphertext_len > MAX_CIPHERTEXT_CHUNK_BYTES {
752 dek.zeroize();
753 return Err(integrity(format!(
754 "chunk {index} declares {ciphertext_len} ciphertext bytes, over the \
755 {MAX_CIPHERTEXT_CHUNK_BYTES} a chunk can hold"
756 )));
757 }
758 let body = &mut framed[CHUNK_LEN_BYTES..CHUNK_LEN_BYTES + ciphertext_len];
759 if let Err(e) = file.read_exact(body).await {
760 dek.zeroize();
761 return Err(frame_error("read chunk", e));
762 }
763 let opened = container::open_chunk(
767 aad,
768 &dek,
769 &header.stream_nonce,
770 u32::try_from(index).map_err(|_| integrity("chunk index out of range"))?,
771 index == final_index,
772 body,
773 );
774 match opened {
775 Ok(part) => plaintext.extend_from_slice(&part),
776 Err(e) => {
777 dek.zeroize();
778 return Err(e);
779 }
780 }
781 }
782 dek.zeroize();
783
784 let start = (offset - first * chunk) as usize;
785 let stop = start + length as usize;
786 if plaintext.len() < stop {
787 return Err(integrity(format!(
788 "container yielded {} bytes where the requested range needs {stop}",
789 plaintext.len()
790 )));
791 }
792 Ok(plaintext[start..stop].to_vec())
793 }
794
795 async fn stat(&self, address: &BlobAddress) -> Result<Option<BlobDescriptor>, BlobError> {
796 match self.row(address.digest_hex()).await? {
797 None => Ok(None),
798 Some(row) if row.descriptor.tombstoned => Err(BlobError::Tombstoned),
802 Some(row) => Ok(Some(row.descriptor)),
803 }
804 }
805
806 async fn pin(&self, address: &BlobAddress, evidence: &EvidenceId) -> Result<(), BlobError> {
807 self.readable(address).await?;
808 sqlx::query(
809 "INSERT INTO gwk_internal.blob_pin (digest, evidence_id) VALUES ($1, $2) \
810 ON CONFLICT (digest, evidence_id) DO NOTHING",
811 )
812 .bind(address.digest_hex())
813 .bind(evidence.as_str())
814 .execute(&self.pool)
815 .await
816 .map_err(|e| storage("pin blob", e))?;
817 Ok(())
818 }
819
820 async fn unpin(&self, address: &BlobAddress, evidence: &EvidenceId) -> Result<(), BlobError> {
821 if self.row(address.digest_hex()).await?.is_none() {
824 return Err(BlobError::NotFound);
825 }
826 sqlx::query("DELETE FROM gwk_internal.blob_pin WHERE digest = $1 AND evidence_id = $2")
828 .bind(address.digest_hex())
829 .bind(evidence.as_str())
830 .execute(&self.pool)
831 .await
832 .map_err(|e| storage("unpin blob", e))?;
833 Ok(())
834 }
835
836 async fn sweep(&self) -> Result<Vec<BlobAddress>, BlobError> {
837 self.expire_uploads().await?;
838 let swept: Vec<String> = sqlx::query_scalar(concat!(
841 "DELETE FROM gwk_internal.blob WHERE digest IN (",
842 unreferenced!(),
843 ") RETURNING digest"
844 ))
845 .fetch_all(&self.pool)
846 .await
847 .map_err(|e| storage("sweep blobs", e))?;
848
849 let mut removed = Vec::with_capacity(swept.len());
850 for digest in swept {
851 let _ = fs::remove_file(self.container_path(&digest)).await;
852 removed
853 .push(BlobAddress::from_digest(&digest).map_err(|e| storage("swept digest", e))?);
854 }
855 Ok(removed)
856 }
857
858 async fn shred(&self, address: &BlobAddress) -> Result<(), BlobError> {
859 let Some(row) = self.row(address.digest_hex()).await? else {
860 return Err(BlobError::NotFound);
861 };
862 if row.descriptor.tombstoned {
864 return Ok(());
865 }
866 if row.descriptor.pinned {
867 return Err(BlobError::Pinned);
868 }
869 let updated = sqlx::query(
876 "UPDATE gwk_internal.blob \
877 SET wrap_nonce = NULL, wrapped_dek = NULL, tombstoned_at = now() \
878 WHERE digest = $1 AND tombstoned_at IS NULL \
879 AND NOT EXISTS (SELECT 1 FROM gwk_internal.blob_pin p WHERE p.digest = $1)",
880 )
881 .bind(address.digest_hex())
882 .execute(&self.pool)
883 .await
884 .map_err(|e| storage("shred blob", e))?;
885 if updated.rows_affected() == 0 {
886 return Err(BlobError::Pinned);
888 }
889 let _ = fs::remove_file(self.container_path(address.digest_hex())).await;
890 Ok(())
891 }
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897
898 #[test]
899 fn only_a_minted_upload_id_can_name_a_file() {
900 assert!(is_upload_id(&"a".repeat(UPLOAD_ID_HEX_LEN)));
901 assert!(is_upload_id("0123456789abcdef0123456789abcdef"));
902 for bad in [
903 "",
904 "..",
905 "../../etc/passwd",
906 &"a".repeat(UPLOAD_ID_HEX_LEN - 1),
907 &"a".repeat(UPLOAD_ID_HEX_LEN + 1),
908 &"A".repeat(UPLOAD_ID_HEX_LEN),
909 &"g".repeat(UPLOAD_ID_HEX_LEN),
910 "0123456789abcdef0123456789abcde/",
911 "0123456789abcdef0123456789abcd.f",
912 ] {
913 assert!(!is_upload_id(bad), "accepted {bad:?}");
914 }
915 }
916}