Skip to main content

gwk_kernel/blob/
store.rs

1//! The [`BlobStore`]: containers on a filesystem, keys and bookkeeping in
2//! PostgreSQL.
3//!
4//! The split is the design. Ciphertext is large, immutable, and content-named,
5//! which is what a filesystem is good at; the wrapped DEK, the pins, and the
6//! tombstone are small, mutable, and have to change atomically, which is what a
7//! database is good at. Putting the key in the row rather than the file is what
8//! makes rewrap and crypto-shred single writes (ADR 0003, and the container
9//! module's own docs).
10//!
11//! Nothing here takes a path from a caller. A committed blob lives at a path
12//! derived from its validated 64-hex digest, and an upload lives at a path
13//! derived from an id this store minted — [`is_upload_id`] re-checks that shape
14//! on the way back in, because a [`BlobUploadId`] is a plain string newtype that
15//! a caller can build out of anything.
16//!
17//! Write ordering is chosen so every crash window leaves the SAFE remainder:
18//!
19//! * commit writes the container, then the row. A crash between them leaves a
20//!   file whose key exists nowhere — inert ciphertext, and a retried commit
21//!   overwrites it.
22//! * shred writes the tombstone and drops the key, then unlinks. A crash
23//!   between them leaves an unreadable blob, never a readable one.
24//! * sweep deletes the row, then unlinks, for the same reason.
25
26use 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
44/// How long an uncommitted upload survives. Enforced on the way in as well as
45/// by [`PgBlobStore::expire_uploads`], so an expired upload is dead even on a
46/// deployment where nothing has swept yet.
47pub const UPLOAD_EXPIRY_SECS: i64 = 3600;
48
49/// Hex width of a minted upload id.
50const UPLOAD_ID_HEX_LEN: usize = 32;
51
52/// The one column list a descriptor is built from. Selected `FROM
53/// gwk_internal.blob b`, which the pin sub-select refers to.
54///
55/// A macro rather than a `const` so call sites can `concat!` it into a real
56/// string LITERAL: sqlx 0.9 refuses a runtime-built query unless it is wrapped
57/// in `AssertSqlSafe`, and an assertion is a promise where `concat!` is a proof.
58macro_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
67/// Blobs no event references and no evidence pins.
68///
69/// The reference test is a lookup against `event_payload_ref_digest`, the
70/// expression index migration 0003 creates — the same extraction, spelled the
71/// same way, or PostgreSQL plans a sequential scan of the whole log per
72/// candidate instead of an index probe.
73///
74/// Tombstoned rows are excluded: their ciphertext is already gone, and the row
75/// is the only remaining evidence that the blob existed and was destroyed
76/// rather than never written. A retention audit needs that difference.
77///
78/// Checkpoints are the SECOND holder of a reference, and not an optional one.
79/// A checkpoint's records blob is committed before the transaction that records
80/// the checkpoint row, so a rolled-back append leaves a records blob nothing
81/// points at — reclaiming that is the whole reason this runs. Reclaiming a
82/// blob a LIVE checkpoint still names is a recovery that cannot run.
83macro_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
103/// A frame read that ran off the end of a container is TRUNCATION, not a disk
104/// fault. The header declares the plaintext size under authentication, so how
105/// many chunks must follow it is not a guess — a file holding fewer has been
106/// cut, and that is an integrity failure the caller can act on rather than an
107/// opaque storage error it can only retry.
108fn 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
117/// Is this the shape this store mints? Lowercase hex of a fixed width — no
118/// separator, no `.`, no `..`, so it cannot name anything but a leaf under the
119/// directory it is joined to.
120pub 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
127/// A committed blob's row, in the form every operation needs it.
128struct BlobRow {
129    descriptor: BlobDescriptor,
130    wrap_nonce: Option<Vec<u8>>,
131    wrapped_dek: Option<Vec<u8>>,
132}
133
134/// Content-addressed blobs: containers under a root directory, key material and
135/// bookkeeping in `gwk_internal`.
136pub struct PgBlobStore {
137    pool: PgPool,
138    config: BlobConfig,
139}
140
141impl PgBlobStore {
142    /// Bind to an initialized database and prepare the root directory.
143    ///
144    /// Creating the directories here rather than lazily means a root that is
145    /// unwritable is a startup failure, not a failure of the first upload that
146    /// happens to arrive in production.
147    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    /// Where a committed container lives: two levels of two hex characters,
171    /// then the digest. Sharded because one directory holding every blob in a
172    /// deployment is a directory nothing can list.
173    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    /// The staged container a commit builds before it is renamed into place.
185    fn staging_path(&self, upload_id: &str) -> PathBuf {
186        self.upload_dir().join(format!("{upload_id}.container"))
187    }
188
189    /// Validate an id the caller handed back. A shape this store never mints
190    /// cannot name anything it wrote, so the honest answer is `NotFound` — not
191    /// a message confirming what the id looked like.
192    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    /// Drop uploads that outlived [`UPLOAD_EXPIRY_SECS`], with their files.
201    ///
202    /// Called from `open`, from `begin`, and from `sweep` rather than by a
203    /// timer: an expiry only has to hold for a store somebody is using, and a
204    /// background task would be a second thing to supervise for no more
205    /// guarantee than this.
206    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    /// Best-effort removal of an upload's two possible files. Failures are
223    /// ignored on purpose: the row is already gone, so the upload is over
224    /// either way, and a leftover file is reclaimed by the next expiry that
225    /// reuses the id — never by refusing the operation that just succeeded.
226    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    /// Read one committed blob's row.
232    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    /// The row, or the reason there is nothing to read.
279    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    /// Rewrap every blob this KEK label covers under `new_kek`, touching no
291    /// ciphertext.
292    ///
293    /// The label does NOT change: it is inside each container's authenticated
294    /// header, so a rewrap that relabeled would invalidate the very AAD the new
295    /// wrap is bound to. Rotation replaces the key behind the name.
296    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            // One row, one statement: a rotation interrupted here has rewrapped
321            // a prefix of the blobs and left the rest on the old key, which is
322            // exactly the state a re-run finishes from.
323            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    /// Open a container and read its header — everything the AAD covers, and
339    /// nothing else.
340    ///
341    /// The descriptor supplies the two variable-length fields, so this reads
342    /// the header's EXACT width. Reading a maximal header instead would cost
343    /// 128 KiB per call, which a ranged read makes once per range: reassembling
344    /// a large blob would spend more on re-reading the same 90 bytes than on
345    /// the blob.
346    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        // `decode` re-derives the length from the FILE's own `u16` fields. If it
360        // lands anywhere but where the row predicted, the AAD would be the wrong
361        // bytes — so this is a refusal, not a reason to read again with a
362        // corrected length.
363        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
373/// The key material of a blob that has not been shredded.
374fn key_material(
375    row: &BlobRow,
376) -> Result<([u8; WRAP_NONCE_BYTES], [u8; WRAPPED_DEK_BYTES]), BlobError> {
377    // The CHECK constraint ties these to the tombstone, so reaching here with
378    // either missing means the row was edited around the constraint.
379    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        // Reclaim before minting. An abandoned upload holds a staged file, so
408        // the expiry has to be driven by something that actually runs, and the
409        // path that creates new ones is the one guaranteed to.
410        self.expire_uploads().await?;
411
412        let id = container::hex_lower(&container::generate::<aead::consts::U16>()?.0);
413        // The row first: if creating the file fails, there is no orphan row to
414        // find, because the insert has not committed anything the caller can
415        // reach without the file.
416        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        // The declared size is a budget, not a hint. Without this an upload can
477        // fill the disk regardless of what it said it would write.
478        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        // Written AT its offset rather than appended, so a chunk replayed after
486        // a crash between the write and the row update lands on its own bytes
487        // instead of after them.
488        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        // ponytail: the whole plaintext in memory. The container's header binds
553        // the digest and the header is the AAD, so NOTHING can be sealed until
554        // the last chunk has arrived — a container is a two-pass write however
555        // this is written. If blobs ever outgrow a process's memory, make the
556        // second pass stream the staged file chunk-by-chunk into the container
557        // instead of reading it whole; the format already supports it.
558        //
559        // Exactly `written` bytes, not the file's length: a chunk replayed at a
560        // shorter length can leave stale bytes past the offset the row records,
561        // and the row is what the caller's own accounting agreed to.
562        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            // A shredded address stays shredded. Crypto-shred is a retention
589            // decision about an address, and re-presenting the bytes is not an
590            // appeal of it — a store that resurrected here would let anyone who
591            // kept a copy undo a deletion the audit log calls final.
592            if existing.descriptor.tombstoned {
593                return Err(BlobError::Tombstoned);
594            }
595            // Dedup requires digest, size AND media type to match (ADR 0003).
596            // Size follows from the digest, so media type is the only one that
597            // can disagree — and it cannot mean "a second blob", because the
598            // address is the digest alone and both would answer to it. It means
599            // the caller and the store disagree about what these bytes are.
600            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        // Staged then renamed: a reader never sees a partially written
618        // container, because the name only appears once the bytes are all
619        // there. The staging file is in the same directory tree as its
620        // destination, so the rename is within one filesystem and atomic.
621        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        // The row last. A crash before it leaves a container whose key exists
636        // nowhere — unreadable by construction, and overwritten by the retry.
637        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        // `None` means a concurrent commit of the identical blob won the
664        // insert. Its container is at the same path and holds the same
665        // plaintext, so that is a dedup hit that happened to race, not a
666        // failure.
667        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        // Clamped rather than refused, matching the event store's read bound: a
698        // huge length is a request for "as much as you'll give me". The ceiling
699        // is one chunk, which is also what the wire ships in, so this never
700        // buffers more than two chunks to answer.
701        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        // The row says how big the blob is and the header says so too, under
713        // authentication. They are written together and can only disagree if
714        // one was edited, and the range arithmetic below trusts both.
715        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            // The framing is the one unauthenticated part of a container, so a
749            // length is checked against the format's own ceiling before it is
750            // used to size a read.
751            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            // `last` comes from the DECLARED size, not from where the file
764            // ends — the flag is authenticated, so deriving it from the file
765            // would let a truncation define its own final chunk.
766            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            // Not `Ok(None)`: "never existed" and "existed and was destroyed"
799            // are different answers, and a retention audit is asking for the
800            // second one.
801            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        // Deliberately NOT `readable`: a shredded blob's pins still have to be
822        // releasable, or a retention hold outlives the data it was holding.
823        if self.row(address.digest_hex()).await?.is_none() {
824            return Err(BlobError::NotFound);
825        }
826        // Releasing a pin that is not held is the state the caller asked for.
827        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        // The row goes first and the file second, so an interruption leaves a
839        // file nothing can decrypt rather than a row pointing at nothing.
840        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        // Shred is terminal, so re-running it is the state the caller wants.
863        if row.descriptor.tombstoned {
864            return Ok(());
865        }
866        if row.descriptor.pinned {
867            return Err(BlobError::Pinned);
868        }
869        // The tombstone and the key removal are ONE statement, and it commits
870        // before any ciphertext is touched (the port's ordering requirement).
871        // A crash after this point leaves a blob that answers `Tombstoned`
872        // while its container is still on disk — unreadable, which is the whole
873        // guarantee. The reverse order would leave a window where the key is
874        // still live and the audit trail already says it is not.
875        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            // Something pinned it between the read and the write.
887            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}