Skip to main content

tensor_wasm_artifacts/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Unified content-addressed signed artifact store (roadmap feature #9).
5//!
6//! Folds the JIT L2 disk cache (`tensor-wasm-jit::cache::DiskCache`) and the
7//! snapshot store (`tensor-wasm-snapshot::SnapshotWriter`/`SnapshotReader`)
8//! into one primitive: a content-addressed, HMAC-signed, on-disk byte
9//! blob keyed by BLAKE3 of the payload.
10//!
11//! ## v0.3.7 status: scaffold
12//!
13//! The trait + an in-memory impl + a disk impl land. Migration of the
14//! JIT cache and snapshot crate to use this store is a v0.4 follow-up;
15//! today they continue to use their own format. The new crate provides
16//! the reference shape both implementations will converge on.
17//!
18//! ## Format
19//!
20//! ```text
21//! magic(16) || version(4) || content_hash(32) || zstd(payload) || hmac_tag(32)
22//! ```
23//!
24//! The HMAC covers magic..end-of-zstd. Verification is constant-time
25//! via `subtle::ConstantTimeEq`. Key rotation: filenames include the
26//! first 8 bytes of `blake3(hmac_key)` so distinct keys partition the
27//! on-disk namespace cleanly.
28
29use std::collections::HashMap;
30use std::fs::File;
31use std::io::{BufReader, BufWriter, Read, Write};
32use std::path::PathBuf;
33use std::sync::Arc;
34
35use serde::{Deserialize, Serialize};
36use thiserror::Error;
37use tracing::warn;
38use zeroize::Zeroizing;
39
40/// I/O buffer size used by both the streaming `put` writer and the
41/// streaming `get` reader. 64 KiB matches the slab the zstd CLI prefers
42/// and is large enough to keep syscall overhead negligible on big blobs
43/// without wasting RAM on tiny ones.
44const STREAM_BUF_LEN: usize = 64 * 1024;
45
46/// Magic bytes identifying a TensorWasm unified-artifact blob.
47///
48/// Read by [`DiskArtifactStore::get`] before any HMAC work so foreign or
49/// stale blobs short-circuit cheaply. The literal is exactly 16 ASCII
50/// bytes — do not change without bumping [`ARTIFACT_VERSION`].
51pub const ARTIFACT_MAGIC: [u8; 16] = *b"twasm-artifact01";
52
53/// On-disk schema version for the unified artifact envelope.
54pub const ARTIFACT_VERSION: u32 = 1;
55
56/// Length of the trailing HMAC tag (HMAC-SHA256 output size).
57pub const ARTIFACT_HMAC_LEN: usize = 32;
58
59/// Length of the fixed header that precedes the zstd body:
60/// `magic(16) || version(4) || content_hash(32)`.
61pub const ARTIFACT_HEADER_LEN: usize = 16 + 4 + 32;
62
63/// Default zstd compression level. Matches `tensor-wasm-snapshot`'s
64/// `DEFAULT_ZSTD_LEVEL` so the two stores converge on the same setting.
65pub const DEFAULT_ZSTD_LEVEL: i32 = 3;
66
67/// Hard ceiling on the size of an individual payload accepted by
68/// [`DiskArtifactStore::put`].
69///
70/// Without this cap an attacker who can drive `put` could request an
71/// allocation proportional to an arbitrary input length, exhausting
72/// process memory. 256 MiB matches the snapshot reader's
73/// `MAX_DECOMPRESSED_BYTES` so the store's ingest ceiling lines up with
74/// the largest legitimately-restorable snapshot today.
75pub const MAX_PAYLOAD_LEN: usize = 256 * 1024 * 1024;
76
77/// Hard ceiling on the size of the decompressed body emitted by
78/// [`DiskArtifactStore::get`].
79///
80/// Defends against zstd "zip bomb" inputs that decompress at very high
81/// ratios (a few MB of attacker-controlled bytes can otherwise expand
82/// to gigabytes). The decoder is driven through
83/// [`std::io::Read::take`] with a probe of `MAX_DECOMPRESSED_LEN + 1`
84/// so we can distinguish "exactly cap" (allowed) from ">cap" (rejected)
85/// without ever allocating past the cap.
86///
87/// SECURITY (memory-amplification fix): this MUST stay tied to
88/// [`MAX_PAYLOAD_LEN`]. Every blob the store can hold was put-capped at
89/// `MAX_PAYLOAD_LEN` *uncompressed* bytes, so a legitimate decode never
90/// needs to yield more than that. Sizing the read cap larger (the old
91/// 1 GiB = 4x value) handed an attacker holding a valid/leaked key a
92/// 4x memory-amplification primitive: a blob that put refused at
93/// 256 MiB could still be hand-crafted to decompress to ~1 GiB on the
94/// read path. We therefore pin the read cap to the put cap plus a small
95/// fixed framing slack (a few KiB) so no legitimately-`put` blob — which
96/// is at most `MAX_PAYLOAD_LEN` uncompressed — is ever rejected, while
97/// the read path can no longer be coerced into a larger allocation than
98/// the write path would have admitted.
99pub const MAX_DECOMPRESSED_LEN: usize = MAX_PAYLOAD_LEN + 8 * 1024;
100
101/// Errors returned by [`ArtifactStore`] implementations.
102///
103/// `Io` deliberately collapses the inner [`std::io::Error`] into a
104/// unit-variant so the type stays `PartialEq`-able by callers that
105/// match on it; the underlying error is logged at the call site. The
106/// JIT L2 cache uses the same "log + swallow" convention for I/O
107/// failures and treats them as a miss.
108#[derive(Debug, Error)]
109pub enum ArtifactError {
110    #[error("artifact not found: {0}")]
111    NotFound(String),
112    #[error("magic mismatch")]
113    BadMagic,
114    #[error("unsupported version: {0}")]
115    BadVersion(u32),
116    #[error("HMAC verification failed")]
117    BadHmac,
118    #[error("content hash mismatch (expected {expected}, got {actual})")]
119    HashMismatch { expected: String, actual: String },
120    #[error("zstd decompression failed: {0}")]
121    Decompression(String),
122    /// (De)serialisation of an [`ArtifactMetadata`] sidecar failed. Carries
123    /// the serde error rendered to a string so the variant stays
124    /// `PartialEq`-compatible with the rest of [`ArtifactError`] (which
125    /// deliberately avoids embedding non-`Eq` inner error types). Only
126    /// reachable through the metadata-aware paths
127    /// ([`DiskArtifactStore::put_with_metadata`] /
128    /// [`DiskArtifactStore::metadata`]); the plain `put`/`get` round-trip
129    /// never touches a sidecar.
130    #[error("artifact metadata codec error: {0}")]
131    Metadata(String),
132    /// Either the payload handed to `put` exceeded [`MAX_PAYLOAD_LEN`],
133    /// or the body handed to `get` decompressed to more than
134    /// [`MAX_DECOMPRESSED_LEN`] bytes. Carries the offending size and
135    /// the cap that was tripped so operators can tell which side of the
136    /// round-trip refused the request. Both fields are `usize` (i.e.
137    /// `Eq`) so this variant stays compatible with any future
138    /// `PartialEq` derive on [`ArtifactError`].
139    #[error("artifact too large: {actual} bytes exceeds cap of {limit} bytes")]
140    TooLarge { actual: usize, limit: usize },
141    #[error("I/O error")]
142    Io,
143}
144
145/// 32-byte BLAKE3 content hash. The `put` path computes this from the
146/// uncompressed payload; the `get` path recomputes it from the decoded
147/// body and rejects on mismatch.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
149pub struct ContentHash([u8; 32]);
150
151impl std::fmt::Display for ContentHash {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        for b in &self.0 {
154            write!(f, "{:02x}", b)?;
155        }
156        Ok(())
157    }
158}
159
160impl ContentHash {
161    /// Compute the content hash of `payload` (BLAKE3 of the uncompressed bytes).
162    pub fn of(payload: &[u8]) -> Self {
163        ContentHash(blake3::hash(payload).into())
164    }
165
166    /// Wrap 32 raw bytes as a [`ContentHash`]. The bytes are assumed to
167    /// be a BLAKE3 digest; this constructor does no hashing. Used by the
168    /// disk store when reconstructing hashes from on-disk filenames and
169    /// by callers that already hold a digest.
170    pub fn from_bytes(bytes: [u8; 32]) -> Self {
171        ContentHash(bytes)
172    }
173
174    /// Borrow the raw 32-byte digest. Counterpart to [`Self::from_bytes`].
175    pub fn as_bytes(&self) -> &[u8; 32] {
176        &self.0
177    }
178
179    /// Hex representation, equivalent to `format!("{self}")`. Useful at
180    /// call sites that want a `String` without going through the
181    /// formatter.
182    pub fn to_hex(&self) -> String {
183        format!("{self}")
184    }
185}
186
187/// Artifact store trait. v0.3.7 ships an in-memory and a disk implementation.
188///
189/// Implementations MUST be `Send + Sync` so the store can be shared
190/// across worker threads behind an `Arc`. They MUST also be safe under
191/// concurrent `put` for the same payload — a put after a put for the
192/// same content hash is idempotent.
193pub trait ArtifactStore: Send + Sync {
194    /// Insert `payload` into the store. The returned [`ContentHash`] is
195    /// `blake3(payload)`; repeated calls with identical payloads return
196    /// identical hashes and overwrite the existing entry in place.
197    fn put(&self, payload: &[u8]) -> Result<ContentHash, ArtifactError>;
198    /// Fetch the payload previously inserted under `hash`. Returns
199    /// [`ArtifactError::NotFound`] on a genuine miss; integrity-failure
200    /// variants ([`ArtifactError::BadMagic`], [`ArtifactError::BadVersion`],
201    /// [`ArtifactError::BadHmac`], [`ArtifactError::HashMismatch`]) are
202    /// returned when a record was present but the format checks rejected
203    /// it.
204    fn get(&self, hash: &ContentHash) -> Result<Vec<u8>, ArtifactError>;
205    /// Stream the verified-then-decoded body of `hash` into `out`,
206    /// returning the number of bytes written. This completes the
207    /// streaming story [`Self::put`] already has on the write side: where
208    /// [`Self::get`] returns an owned `Vec<u8>` (up to
209    /// [`MAX_DECOMPRESSED_LEN`] resident),
210    /// `get_to` lets a caller pipe a large artifact straight into a file,
211    /// socket, or hashing sink without first materialising the whole
212    /// decoded payload as a return value.
213    ///
214    /// ## Integrity ordering
215    ///
216    /// The HMAC covers the *entire* compressed blob, so it cannot be
217    /// verified until the last body byte has been seen. To preserve the
218    /// crate-wide invariant — **no unverified bytes are ever exposed to a
219    /// caller** — implementations MUST authenticate the blob *before*
220    /// any decoded byte reaches `out`. [`DiskArtifactStore`] does this
221    /// with a two-pass scheme (pass 1: stream the compressed body through
222    /// the HMAC to verify; pass 2: re-open and stream-decode directly to
223    /// `out`), so peak heap stays bounded by the I/O buffers regardless
224    /// of payload size and `out` only ever sees authenticated bytes. The
225    /// error variants match [`Self::get`].
226    ///
227    /// The default implementation delegates to [`Self::get`] and copies
228    /// the resulting buffer into `out`; it is correct (the `Vec` `get`
229    /// returns is already verified) but not memory-bounded. Backends that
230    /// can stream override it.
231    fn get_to(&self, hash: &ContentHash, out: &mut dyn Write) -> Result<u64, ArtifactError> {
232        let bytes = self.get(hash)?;
233        out.write_all(&bytes).map_err(|e| {
234            warn!(target: "tensor_wasm_artifacts", error = %e, "get_to default writer write failed");
235            ArtifactError::Io
236        })?;
237        Ok(bytes.len() as u64)
238    }
239    /// Enumerate the content hashes currently stored. Order is
240    /// implementation-defined; callers that need a deterministic order
241    /// must sort the result themselves.
242    ///
243    /// Returns [`ArtifactError::Io`] on an underlying enumeration
244    /// failure (e.g. a `read_dir` permissions/I/O fault). This is
245    /// deliberately distinct from an empty result so GC/audit callers
246    /// can tell "store is empty" apart from "could not read the store".
247    fn list(&self) -> Result<Vec<ContentHash>, ArtifactError>;
248    /// Cheap existence probe for `hash`: returns `true` if an entry is
249    /// present, `false` otherwise. This is deliberately a stat-only
250    /// check — it does NOT decode, decompress, or HMAC-verify the
251    /// record, so it is dramatically cheaper than [`Self::get`] and
252    /// suitable for the GC/audit roadmap's "is this content already
253    /// resident?" question.
254    ///
255    /// Because no integrity work happens, a `true` result means only
256    /// that a file (disk) or map entry (in-memory) exists under the
257    /// content-addressed key; a subsequent [`Self::get`] can still fail
258    /// with an integrity variant if that record was tampered with.
259    ///
260    /// For [`DiskArtifactStore`] this probe is rotation-aware: it resolves
261    /// against every accepted read key (active or retired), so a blob
262    /// written under a now-retired key — and still visible to [`Self::get`]
263    /// — also reports `true` here, rather than appearing absent and leaking
264    /// past GC.
265    ///
266    /// Returns [`ArtifactError::Io`] only on an underlying probe fault
267    /// that is neither "present" nor "absent" (e.g. a `metadata` call
268    /// failing for a reason other than not-found).
269    fn contains(&self, hash: &ContentHash) -> Result<bool, ArtifactError>;
270    /// Remove the entry stored under `hash`. Returns `true` if a record
271    /// was removed, `false` if nothing was stored under `hash` (a
272    /// no-op delete is not an error — it mirrors `HashMap::remove`'s
273    /// "was it there?" boolean and the POSIX `unlink`-of-missing
274    /// convention GC callers expect).
275    ///
276    /// For [`DiskArtifactStore`] this is rotation-aware: it resolves which
277    /// accepted key (active or retired) actually holds the blob — the same
278    /// resolution [`Self::get`] / [`Self::list`] use — and unlinks the
279    /// `{hash}.{key_fp}.bin` file (plus any sidecar) under THAT key, so a
280    /// blob written under a now-retired key can still be GC'd. For
281    /// [`InMemoryArtifactStore`] it drops the map entry. Returns
282    /// [`ArtifactError::Io`] on an underlying delete fault other than
283    /// not-found.
284    fn remove(&self, hash: &ContentHash) -> Result<bool, ArtifactError>;
285}
286
287// =====================================================================
288// Key provider — pluggable signing key + accepted-for-read key set
289// =====================================================================
290
291/// Supplies the signing material a [`DiskArtifactStore`] uses, abstracting
292/// over key rotation.
293///
294/// A store always *writes* under a single active key (so a fresh `put`
295/// lands under one fingerprint and round-trips cleanly), but during a
296/// rotation it must still be able to *read* blobs that were written under
297/// an older, now-retired key. A `KeyProvider` therefore exposes two
298/// things:
299///
300/// * [`Self::active_key`] — the one key new `put`s sign with, and the key
301///   `list` reports the store's "own" namespace under; and
302/// * [`Self::read_keys`] — every key a `get` / `list` is allowed to try,
303///   newest first. The active key MUST appear in this set.
304///
305/// On a `get`, the store tries each read key's namespaced file in turn
306/// (keys partition the on-disk namespace by fingerprint, so at most one
307/// can match a given content hash). On a `list`, the store unions the
308/// hashes visible under every read key, so an audit sees blobs across the
309/// rotation boundary.
310///
311/// Implementations MUST be `Send + Sync` (the store is shared behind an
312/// `Arc`). Keys are returned by value (`[u8; 32]`) rather than borrowed so
313/// a provider backing onto a rotating KMS can mint them on demand.
314pub trait KeyProvider: Send + Sync {
315    /// The key new `put`s sign with. MUST be one of [`Self::read_keys`].
316    fn active_key(&self) -> [u8; 32];
317    /// Every key a read (`get` / `list`) may try, conventionally newest
318    /// first so the common "just-written under the active key" case hits
319    /// on the first probe. The active key MUST be present.
320    fn read_keys(&self) -> Vec<[u8; 32]>;
321}
322
323/// The default single-key provider: writes and reads under exactly one
324/// key. This is what [`DiskArtifactStore::new`] wraps the caller's
325/// `[u8; 32]` in, so the historical single-key constructor keeps its exact
326/// behaviour (one active key, that same key the only accepted read key).
327///
328/// The key is held in a [`Zeroizing`] so it is scrubbed on drop, matching
329/// the store's own `hmac_key` handling.
330pub struct SingleKeyProvider {
331    key: Zeroizing<[u8; 32]>,
332}
333
334impl SingleKeyProvider {
335    /// Wrap a single key as a provider.
336    pub fn new(key: [u8; 32]) -> Self {
337        Self {
338            key: Zeroizing::new(key),
339        }
340    }
341}
342
343impl KeyProvider for SingleKeyProvider {
344    fn active_key(&self) -> [u8; 32] {
345        *self.key
346    }
347    fn read_keys(&self) -> Vec<[u8; 32]> {
348        vec![*self.key]
349    }
350}
351
352/// A rotation-aware key provider: one active (write) key plus an ordered
353/// list of additional keys accepted for reads only.
354///
355/// During a rotation an operator constructs this with the new key as
356/// `active` and the previous key(s) in `also_accept`; new `put`s sign
357/// under `active`, while `get` / `list` continue to see blobs written
358/// under the retired keys until they are migrated or GC'd. The active key
359/// is automatically included in [`KeyProvider::read_keys`] (first), so the
360/// caller passes only the *extra* accepted keys.
361pub struct RotatingKeyProvider {
362    active: Zeroizing<[u8; 32]>,
363    also_accept: Vec<Zeroizing<[u8; 32]>>,
364}
365
366impl RotatingKeyProvider {
367    /// Build a provider whose write/active key is `active` and which also
368    /// accepts every key in `also_accept` for reads. Duplicates of the
369    /// active key in `also_accept` are harmless (the store dedups by
370    /// fingerprint when listing).
371    pub fn new(active: [u8; 32], also_accept: impl IntoIterator<Item = [u8; 32]>) -> Self {
372        Self {
373            active: Zeroizing::new(active),
374            also_accept: also_accept.into_iter().map(Zeroizing::new).collect(),
375        }
376    }
377}
378
379impl KeyProvider for RotatingKeyProvider {
380    fn active_key(&self) -> [u8; 32] {
381        *self.active
382    }
383    fn read_keys(&self) -> Vec<[u8; 32]> {
384        // Active key first (the hot path: reading something just written),
385        // then the retired keys in the order supplied.
386        let mut keys = Vec::with_capacity(1 + self.also_accept.len());
387        keys.push(*self.active);
388        keys.extend(self.also_accept.iter().map(|k| **k));
389        keys
390    }
391}
392
393// =====================================================================
394// Artifact metadata sidecar
395// =====================================================================
396
397/// Optional per-artifact metadata, stored as a serde-encoded sidecar next
398/// to the blob.
399///
400/// The blob itself is opaque content-addressed bytes; nothing in the
401/// envelope records *when* or *from where* it was produced. For
402/// `list`-based auditing and GC (roadmap feature #9) that provenance is
403/// useful, so [`DiskArtifactStore::put_with_metadata`] writes one of these
404/// alongside the blob and [`DiskArtifactStore::metadata`] reads it back.
405///
406/// Metadata is strictly optional: a plain [`ArtifactStore::put`] writes no
407/// sidecar, and [`DiskArtifactStore::metadata`] returns
408/// [`ArtifactError::NotFound`] for a blob that has none.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410pub struct ArtifactMetadata {
411    /// Wall-clock creation time in Unix milliseconds, as recorded by the
412    /// writer at `put_with_metadata` time.
413    pub created_unix_ms: u64,
414    /// Length in bytes of the *uncompressed* payload. Lets an auditor size
415    /// the store's logical footprint without decoding every blob.
416    pub original_len: u64,
417    /// Free-form origin tag (e.g. `"jit-l2"`, `"snapshot"`, `"import"`) so
418    /// GC policy can treat artifacts from different producers differently.
419    pub source_tier: String,
420}
421
422// =====================================================================
423// HMAC helpers and tee adapters — shared by `DiskArtifactStore` put and
424// get paths so the streaming sides cannot drift on hash/key choice.
425// =====================================================================
426
427/// HMAC-SHA256 instance type used by both the streaming `put` and `get`
428/// paths. Centralising the type alias keeps the two sides from drifting
429/// on hash/key choice.
430type ArtifactMac = hmac::Hmac<sha2::Sha256>;
431
432/// Construct a fresh incremental HMAC instance over `key`. Both the
433/// streaming put and streaming get build one of these and feed bytes in
434/// chunk-by-chunk via `Mac::update`, then `finalize_into_tag`.
435fn new_mac(key: &[u8; 32]) -> ArtifactMac {
436    use hmac::Mac;
437    // `new_from_slice` only errors on invalid key length; ours is a
438    // fixed 32 bytes so the unwrap is sound (mirrors the same pattern
439    // in `tensor-wasm-snapshot::SnapshotWriter::capture`).
440    <ArtifactMac as Mac>::new_from_slice(&key[..]).expect("HMAC-SHA256 accepts any 32-byte key")
441}
442
443/// Finalise an incremental HMAC into the fixed-length tag.
444fn finalize_into_tag(mac: ArtifactMac) -> [u8; ARTIFACT_HMAC_LEN] {
445    use hmac::Mac;
446    let out = mac.finalize().into_bytes();
447    let mut tag = [0u8; ARTIFACT_HMAC_LEN];
448    tag.copy_from_slice(out.as_slice());
449    tag
450}
451
452/// 8-byte hex fingerprint of the HMAC key. Used to partition the on-disk
453/// namespace when multiple stores share a directory under different
454/// keys — without this, two writers with different keys would clobber
455/// each other's files on a hash collision, and the second reader would
456/// always fail the HMAC check.
457fn key_fingerprint_hex(key: &[u8; 32]) -> String {
458    let h = blake3::hash(&key[..]);
459    h.as_bytes()[..8]
460        .iter()
461        .map(|b| format!("{:02x}", b))
462        .collect()
463}
464
465/// `Write` adapter that tees every byte into both an inner writer (the
466/// `BufWriter<File>` backing the temp envelope) and an HMAC instance
467/// (the streaming MAC over the same bytes).
468///
469/// This is what lets `put` avoid the intermediate framed `Vec`: as zstd
470/// emits compressed bytes through its encoder, the encoder writes into a
471/// `MacWriter` whose downstream is the on-disk file. The HMAC sees the
472/// exact byte sequence that lands on disk (header + zstd body) without
473/// any second pass over a materialised buffer.
474struct MacWriter<'a, W: Write> {
475    inner: W,
476    mac: &'a mut ArtifactMac,
477}
478
479impl<'a, W: Write> MacWriter<'a, W> {
480    fn new(inner: W, mac: &'a mut ArtifactMac) -> Self {
481        Self { inner, mac }
482    }
483}
484
485impl<W: Write> Write for MacWriter<'_, W> {
486    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
487        // Write to the underlying sink first. If the file-write fails we
488        // do NOT update the MAC, so a torn write doesn't leave the MAC
489        // covering bytes that didn't actually reach disk.
490        let n = self.inner.write(buf)?;
491        // Only feed the prefix that the writer actually accepted; this
492        // matches the contract of `Write::write` and keeps the MAC in
493        // sync with the file's byte stream.
494        use hmac::Mac;
495        self.mac.update(&buf[..n]);
496        Ok(n)
497    }
498
499    fn flush(&mut self) -> std::io::Result<()> {
500        self.inner.flush()
501    }
502}
503
504/// `Read` adapter that tees every byte read from an inner reader into
505/// an HMAC instance.
506///
507/// `get` uses this so the body bytes feeding the zstd decoder also feed
508/// the HMAC in a single pass — no second walk of the prefix to compute
509/// the expected tag. The HMAC sees whatever bytes were actually
510/// delivered to the consumer (the decoder), which is exactly the
511/// invariant we need for the MAC to be byte-compatible with `put`.
512struct MacReader<'a, R: Read> {
513    inner: R,
514    mac: &'a mut ArtifactMac,
515}
516
517impl<'a, R: Read> MacReader<'a, R> {
518    fn new(inner: R, mac: &'a mut ArtifactMac) -> Self {
519        Self { inner, mac }
520    }
521}
522
523impl<R: Read> Read for MacReader<'_, R> {
524    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
525        let n = self.inner.read(buf)?;
526        // Feed exactly the bytes the upstream observed. A short read is
527        // fine; the MAC simply sees fewer bytes this round and picks up
528        // the rest on the next call.
529        use hmac::Mac;
530        self.mac.update(&buf[..n]);
531        Ok(n)
532    }
533}
534
535// =====================================================================
536// Disk store
537// =====================================================================
538
539/// On-disk content-addressed signed artifact store.
540///
541/// Format on disk for a single blob:
542///
543/// ```text
544/// magic(16) || version(4) || content_hash(32) || zstd(payload) || hmac_tag(32)
545/// ```
546///
547/// The HMAC covers everything except the trailing 32-byte tag itself.
548/// Verification uses constant-time comparison.
549pub struct DiskArtifactStore {
550    dir: PathBuf,
551    /// Active signing key — the key new `put`s sign with. Cached out of the
552    /// provider once at construction so the hot write path doesn't re-enter
553    /// the provider (a rotating provider may mint keys on demand). Held in a
554    /// `Zeroizing` so it's scrubbed on drop. Reads (`get` / `get_to`) and
555    /// the rotation-aware probes (`contains` / `remove` / `metadata`)
556    /// resolve against [`KeyProvider::read_keys`], not just this key.
557    hmac_key: Zeroizing<[u8; 32]>,
558    /// `blake3(active_key)[..8]` rendered as 16 ascii-hex chars, computed
559    /// once at construction. Used as the active-key filename segment by
560    /// `path_for` / `meta_path_for` (and thus `put` / `put_with_metadata`);
561    /// caching it here avoids re-hashing the active key on every write. The
562    /// rotation-aware read/probe paths derive per-key fingerprints on demand
563    /// via `key_fingerprint_hex` instead.
564    key_fp_hex: String,
565    /// Pluggable source of signing keys. For a single-key store this is a
566    /// [`SingleKeyProvider`]; for a rotating store it yields the active
567    /// key plus the retired keys still accepted for reads. `get` and
568    /// `list` consult [`KeyProvider::read_keys`] so they span a rotation.
569    key_provider: Arc<dyn KeyProvider>,
570}
571
572impl DiskArtifactStore {
573    /// Construct a disk store rooted at `dir`, signing with `hmac_key`.
574    /// The directory is created lazily on the first `put`.
575    ///
576    /// This wraps `hmac_key` in a [`SingleKeyProvider`] internally, so the
577    /// store writes and reads under exactly that one key — identical to
578    /// the historical single-key behaviour. Use
579    /// [`Self::with_key_provider`] for rotation support.
580    pub fn new(dir: PathBuf, hmac_key: [u8; 32]) -> Self {
581        Self::with_key_provider(dir, Arc::new(SingleKeyProvider::new(hmac_key)))
582    }
583
584    /// Construct a disk store rooted at `dir` whose signing keys come from
585    /// `key_provider`.
586    ///
587    /// New `put`s sign under [`KeyProvider::active_key`]; `get` and `list`
588    /// try every key in [`KeyProvider::read_keys`], so a store built with
589    /// a [`RotatingKeyProvider`] can read blobs written under a retired
590    /// key after rotation. The directory is created lazily on the first
591    /// `put`.
592    pub fn with_key_provider(dir: PathBuf, key_provider: Arc<dyn KeyProvider>) -> Self {
593        let active = key_provider.active_key();
594        let key_fp_hex = key_fingerprint_hex(&active);
595        Self {
596            dir,
597            hmac_key: Zeroizing::new(active),
598            key_fp_hex,
599            key_provider,
600        }
601    }
602
603    /// Compute the on-disk path for `hash` under this store's *active*
604    /// key. Counterpart helper [`Self::path_for_key`] resolves under an
605    /// arbitrary accepted read key.
606    ///
607    /// Filename format: `{content_hash_hex}.{key_fp_hex}.bin`. The key
608    /// fingerprint segment partitions the namespace per HMAC key so
609    /// two stores in the same dir under different keys never collide.
610    fn path_for(&self, hash: &ContentHash) -> PathBuf {
611        self.path_for_key(hash, &self.key_fp_hex)
612    }
613
614    /// Compute the on-disk path for `hash` under the key whose fingerprint
615    /// is `key_fp_hex`. Used by the rotation-aware `get` to probe each
616    /// accepted read key's namespace in turn.
617    fn path_for_key(&self, hash: &ContentHash, key_fp_hex: &str) -> PathBuf {
618        let hash_hex = hash.to_string();
619        // The rendered hash MUST be exactly 64 lowercase ascii-hex chars
620        // before it becomes a path component — `ContentHash`'s `Display`
621        // guarantees this (32 bytes, two hex digits each), but assert it
622        // so a future change to the digest size or formatter can never
623        // silently feed a traversal-shaped or wrong-length segment into
624        // `Path::join`.
625        debug_assert!(
626            hash_hex.len() == 64
627                && hash_hex
628                    .bytes()
629                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
630            "ContentHash rendered to an unexpected filename segment: {hash_hex:?}"
631        );
632        self.dir.join(format!("{hash_hex}.{key_fp_hex}.bin"))
633    }
634
635    /// On-disk path for the metadata sidecar of `hash` under the active
636    /// key. Sits next to the blob with a `.meta.json` extension in place
637    /// of `.bin`, so it shares the blob's content-addressed + key-
638    /// partitioned naming and is filtered out of `list` (which only
639    /// matches `.bin`). Counterpart helper [`Self::meta_path_for_key`]
640    /// resolves the sidecar under an arbitrary accepted read key.
641    fn meta_path_for(&self, hash: &ContentHash) -> PathBuf {
642        self.meta_path_for_key(hash, &self.key_fp_hex)
643    }
644
645    /// On-disk path for the metadata sidecar of `hash` under the key whose
646    /// fingerprint is `key_fp_hex`. Used by the rotation-aware `metadata`
647    /// read so a sidecar written under a now-retired key is still found,
648    /// and by `remove` so the sidecar is unlinked under the same key as the
649    /// blob it describes.
650    fn meta_path_for_key(&self, hash: &ContentHash, key_fp_hex: &str) -> PathBuf {
651        let hash_hex = hash.to_string();
652        self.dir.join(format!("{hash_hex}.{key_fp_hex}.meta.json"))
653    }
654
655    /// Insert `payload` (exactly like [`ArtifactStore::put`]) and write an
656    /// [`ArtifactMetadata`] sidecar next to the blob under the active key.
657    ///
658    /// The blob and the sidecar are independent files: the blob is the
659    /// content-addressed envelope, the sidecar is a `.meta.json` serde
660    /// document. A later [`Self::metadata`] reads the sidecar back. Plain
661    /// `put` continues to write no sidecar, so metadata stays optional.
662    ///
663    /// `metadata.original_len` is recorded as supplied by the caller (it
664    /// is provenance, not a re-derived fact); pass `payload.len()` for the
665    /// common "this is the blob I just stored" case.
666    pub fn put_with_metadata(
667        &self,
668        payload: &[u8],
669        metadata: &ArtifactMetadata,
670    ) -> Result<ContentHash, ArtifactError> {
671        // Store the blob first; if that fails we never write an orphan
672        // sidecar. (A crash between the two writes can leave a blob with
673        // no sidecar — that's the benign direction: `metadata` simply
674        // reports `NotFound` and the blob still round-trips via `get`.)
675        let hash = self.put(payload)?;
676
677        let encoded = serde_json::to_vec(metadata).map_err(|e| {
678            warn!(target: "tensor_wasm_artifacts", error = %e, "metadata serialize failed");
679            ArtifactError::Metadata(e.to_string())
680        })?;
681
682        // Atomic publish of the sidecar via temp-then-rename, mirroring
683        // the blob's own publish so a torn write never leaves a partial
684        // sidecar a concurrent reader trips over.
685        let meta_path = self.meta_path_for(&hash);
686        let mut tmp = tempfile::NamedTempFile::new_in(&self.dir).map_err(|e| {
687            warn!(target: "tensor_wasm_artifacts", error = %e, "metadata tempfile create failed");
688            ArtifactError::Io
689        })?;
690        tmp.write_all(&encoded).map_err(|e| {
691            warn!(target: "tensor_wasm_artifacts", error = %e, "metadata write failed");
692            ArtifactError::Io
693        })?;
694        // Durability (crash-consistency fix): flush the sidecar's data
695        // before the atomic rename, mirroring the blob publish above.
696        tmp.as_file().sync_all().map_err(|e| {
697            warn!(target: "tensor_wasm_artifacts", error = %e, "metadata fsync (pre-persist) failed");
698            ArtifactError::Io
699        })?;
700        tmp.persist(&meta_path).map_err(|e| {
701            warn!(target: "tensor_wasm_artifacts", error = %e, "metadata persist failed");
702            ArtifactError::Io
703        })?;
704        // Durability: best-effort directory fsync to persist the rename
705        // (tolerant of Windows, where directory sync is unsupported).
706        self.sync_dir_best_effort();
707        Ok(hash)
708    }
709
710    /// Read the [`ArtifactMetadata`] sidecar for `hash` under whichever
711    /// accepted key (active or retired) the blob actually lives under.
712    ///
713    /// Rotation-aware: this resolves the blob's key with the same
714    /// `resolve_read_key` probe `get` / `list` use, then reads the
715    /// sidecar partitioned under that key's fingerprint. A blob written
716    /// under a now-retired key (and its sidecar) is therefore still
717    /// readable here after rotation, matching what `get` exposes.
718    ///
719    /// Returns [`ArtifactError::NotFound`] if no blob exists under any
720    /// accepted key, or if the blob exists but carries no sidecar (e.g. it
721    /// was written via plain `put`). A malformed sidecar surfaces as
722    /// [`ArtifactError::Metadata`].
723    pub fn metadata(&self, hash: &ContentHash) -> Result<ArtifactMetadata, ArtifactError> {
724        // Find which accepted key holds the blob, then read the sidecar
725        // under that same key's fingerprint. If no blob is resolvable the
726        // sidecar (if any) is orphaned/foreign — report NotFound.
727        let (_blob_path, key) = match self.resolve_read_key(hash)? {
728            Some(found) => found,
729            None => return Err(ArtifactError::NotFound(hash.to_string())),
730        };
731        let fp = key_fingerprint_hex(&key);
732        let meta_path = self.meta_path_for_key(hash, &fp);
733        let bytes = match std::fs::read(&meta_path) {
734            Ok(b) => b,
735            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
736                return Err(ArtifactError::NotFound(hash.to_string()));
737            }
738            Err(e) => {
739                warn!(
740                    target: "tensor_wasm_artifacts",
741                    file = %meta_path.display(),
742                    error = %e,
743                    "metadata read failed"
744                );
745                return Err(ArtifactError::Io);
746            }
747        };
748        serde_json::from_slice(&bytes).map_err(|e| {
749            warn!(target: "tensor_wasm_artifacts", error = %e, "metadata deserialize failed");
750            ArtifactError::Metadata(e.to_string())
751        })
752    }
753
754    /// Pass 1 of the streaming `get_to`: verify the HMAC of the blob held
755    /// open by `reader` under `key` *without* exposing any decoded bytes.
756    /// Streams the compressed body through the running MAC and compares the
757    /// trailing tag in constant time. On success returns the validated
758    /// header's content hash and the byte range of the zstd body, so pass
759    /// 2 can rewind the SAME handle and decode without re-reading the
760    /// header.
761    ///
762    /// TOCTOU note: `get_to` opens the file exactly once and passes the
763    /// resulting handle here for pass 1 and to [`Self::decode_to_writer`]
764    /// for pass 2 (after a `seek` back to start). Because both passes read
765    /// the one handle's bytes, the bytes decoded in pass 2 are *exactly*
766    /// the bytes authenticated in pass 1 — a concurrent `remove`+`put` that
767    /// swaps the path's contents between passes cannot smuggle unverified
768    /// bytes to `out` (a swap unlinks the old inode but our open handle
769    /// keeps reading it). `reader` is positioned at the start of the file
770    /// on entry. The caller supplies `file_len` (already stat'd) so this
771    /// does not re-`metadata` the handle.
772    ///
773    /// Returns the same integrity error surface as [`ArtifactStore::get`].
774    fn verify_blob(
775        &self,
776        reader: &mut BufReader<File>,
777        file_len: u64,
778        key: &[u8; 32],
779        path: &std::path::Path,
780    ) -> Result<VerifiedBlob, ArtifactError> {
781        let min_len = (ARTIFACT_HEADER_LEN + ARTIFACT_HMAC_LEN) as u64;
782        if file_len < min_len {
783            return Err(ArtifactError::BadMagic);
784        }
785        let prefix_end = file_len - ARTIFACT_HMAC_LEN as u64;
786        let body_len = prefix_end - ARTIFACT_HEADER_LEN as u64;
787
788        let mut mac = new_mac(key);
789
790        let mut header = [0u8; ARTIFACT_HEADER_LEN];
791        reader.read_exact(&mut header).map_err(|e| {
792            warn!(target: "tensor_wasm_artifacts", file = %path.display(), error = %e, "header read failed");
793            ArtifactError::Io
794        })?;
795        if header[..16] != ARTIFACT_MAGIC {
796            return Err(ArtifactError::BadMagic);
797        }
798        let version = u32::from_le_bytes([header[16], header[17], header[18], header[19]]);
799        if version != ARTIFACT_VERSION {
800            return Err(ArtifactError::BadVersion(version));
801        }
802        let mut hash_on_disk = [0u8; 32];
803        hash_on_disk.copy_from_slice(&header[20..52]);
804        {
805            use hmac::Mac;
806            mac.update(&header);
807        }
808
809        // Stream the whole compressed body through the MAC, discarding the
810        // bytes. We do NOT decode here: the goal of pass 1 is solely to
811        // authenticate, so no decoded byte exists yet to leak.
812        {
813            let mut body_take = Read::take(&mut *reader, body_len);
814            let mut scratch = [0u8; STREAM_BUF_LEN];
815            loop {
816                let n = body_take.read(&mut scratch).map_err(|e| {
817                    warn!(target: "tensor_wasm_artifacts", file = %path.display(), error = %e, "body read failed");
818                    ArtifactError::Io
819                })?;
820                if n == 0 {
821                    break;
822                }
823                use hmac::Mac;
824                mac.update(&scratch[..n]);
825            }
826        }
827
828        let mut tag_bytes = [0u8; ARTIFACT_HMAC_LEN];
829        reader.read_exact(&mut tag_bytes).map_err(|e| {
830            warn!(target: "tensor_wasm_artifacts", file = %path.display(), error = %e, "tag read failed");
831            ArtifactError::Io
832        })?;
833        let expected = finalize_into_tag(mac);
834        use subtle::ConstantTimeEq;
835        if !bool::from(expected.as_slice().ct_eq(&tag_bytes[..])) {
836            warn!(target: "tensor_wasm_artifacts", file = %path.display(), "HMAC mismatch (get_to verify pass)");
837            return Err(ArtifactError::BadHmac);
838        }
839        Ok(VerifiedBlob {
840            hash_on_disk,
841            body_len,
842        })
843    }
844
845    /// Pass 2 of the streaming `get_to`: stream-decode the already-verified
846    /// blob directly into `out`, enforcing the decompressed-size cap and
847    /// recomputing the content hash as defence-in-depth. Only called after
848    /// [`Self::verify_blob`] has authenticated the bytes of the SAME open
849    /// handle, so every byte written to `out` is authenticated.
850    ///
851    /// TOCTOU fix: this consumes the very handle pass 1 verified — the
852    /// caller rewinds it (`seek(SeekFrom::Start(0))`) and hands it back
853    /// here rather than re-opening `path`. Re-opening was the old race: a
854    /// concurrent `remove`+`put` could swap the path's contents between the
855    /// two opens, so pass 2 would decode bytes that pass 1 never
856    /// HMAC-authenticated (the content-hash recheck below caught a swapped
857    /// payload, but only *after* unverified bytes had already streamed to
858    /// `out`). Reusing the handle pins the inode, so the decoded bytes are
859    /// byte-for-byte the authenticated ones. `reader` is positioned at the
860    /// start of the file on entry. `path` is used only for diagnostics.
861    fn decode_to_writer(
862        &self,
863        reader: &mut BufReader<File>,
864        verified: &VerifiedBlob,
865        requested: &ContentHash,
866        out: &mut dyn Write,
867        path: &std::path::Path,
868    ) -> Result<u64, ArtifactError> {
869        // Skip the header — already validated in pass 1.
870        let mut header = [0u8; ARTIFACT_HEADER_LEN];
871        reader.read_exact(&mut header).map_err(|e| {
872            warn!(target: "tensor_wasm_artifacts", error = %e, "header reread failed (decode pass)");
873            ArtifactError::Io
874        })?;
875
876        let cap = MAX_DECOMPRESSED_LEN;
877        let probe_limit = u64::try_from(cap)
878            .ok()
879            .and_then(|c| c.checked_add(1))
880            .unwrap_or(u64::MAX);
881
882        // Decode the body straight into a hashing+counting+capping sink
883        // that forwards to `out`. Because the blob is already
884        // authenticated, streaming decoded bytes to the caller honours
885        // the "no unverified bytes exposed" invariant.
886        let body_take = Read::take(&mut *reader, verified.body_len);
887        let decoder = zstd::stream::read::Decoder::new(body_take).map_err(|e| {
888            warn!(target: "tensor_wasm_artifacts", error = %e, "zstd init failed (decode pass)");
889            ArtifactError::Decompression(e.to_string())
890        })?;
891        let mut sink = HashingWriter::new(out);
892        let copied = std::io::copy(&mut Read::take(decoder, probe_limit), &mut sink);
893        // A `copy` error is one of two things: the downstream writer
894        // refused a byte (the `HashingWriter` records this in
895        // `downstream_failed`, regardless of the io error kind), or the
896        // zstd decoder faulted on the body. Check the downstream flag
897        // first so a writer fault is reported as `Io`, not `Decompression`.
898        let written = match copied {
899            Ok(n) => n,
900            Err(e) => {
901                if sink.downstream_failed {
902                    warn!(target: "tensor_wasm_artifacts", error = %e, "get_to writer write failed");
903                    return Err(ArtifactError::Io);
904                }
905                warn!(target: "tensor_wasm_artifacts", error = %e, "zstd decode failed (decode pass)");
906                return Err(ArtifactError::Decompression(e.to_string()));
907            }
908        };
909        if written > cap as u64 {
910            warn!(
911                target: "tensor_wasm_artifacts",
912                file = %path.display(),
913                actual = written,
914                limit = cap,
915                "rejecting oversized decompressed payload (possible zstd bomb)"
916            );
917            return Err(ArtifactError::TooLarge {
918                actual: written as usize,
919                limit: cap,
920            });
921        }
922        // Defence-in-depth: the streamed bytes must hash to both the
923        // header value and the requested key.
924        let recomputed: [u8; 32] = sink.hasher.finalize().into();
925        if recomputed != verified.hash_on_disk {
926            return Err(ArtifactError::HashMismatch {
927                expected: hex_of(&verified.hash_on_disk),
928                actual: hex_of(&recomputed),
929            });
930        }
931        if recomputed != *requested.as_bytes() {
932            return Err(ArtifactError::HashMismatch {
933                expected: requested.to_string(),
934                actual: hex_of(&recomputed),
935            });
936        }
937        Ok(written)
938    }
939
940    /// Resolve which accepted read key holds `hash`, if any. Probes each
941    /// key from [`KeyProvider::read_keys`] (active first) with a stat-only
942    /// existence check and returns the `(path, key)` of the first match —
943    /// keys partition the namespace by fingerprint, so there is at most
944    /// one. Returns `Ok(None)` on a genuine miss across all keys, and
945    /// [`ArtifactError::Io`] on a probe fault that is neither present nor
946    /// absent.
947    fn resolve_read_key(
948        &self,
949        hash: &ContentHash,
950    ) -> Result<Option<(PathBuf, [u8; 32])>, ArtifactError> {
951        for key in self.key_provider.read_keys() {
952            let fp = key_fingerprint_hex(&key);
953            let path = self.path_for_key(hash, &fp);
954            match std::fs::metadata(&path) {
955                Ok(_) => return Ok(Some((path, key))),
956                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
957                Err(e) => {
958                    warn!(
959                        target: "tensor_wasm_artifacts",
960                        file = %path.display(),
961                        error = %e,
962                        "resolve_read_key metadata probe failed"
963                    );
964                    return Err(ArtifactError::Io);
965                }
966            }
967        }
968        Ok(None)
969    }
970
971    /// Best-effort fsync of the store's containing directory after an
972    /// atomic rename, so the rename (a directory-entry mutation) is
973    /// persisted and not just the file data.
974    ///
975    /// Durability fix (LOW): a crash between `persist` and the OS
976    /// flushing the directory metadata could otherwise resurrect the old
977    /// directory state even though the file data is already on stable
978    /// storage. fsyncing the directory closes that window on POSIX.
979    ///
980    /// This is deliberately best-effort: on Windows you cannot open a
981    /// directory as a syncable `File` (the open or the `sync_all` errors
982    /// with e.g. `PermissionDenied`/`InvalidInput`), so a failure here is
983    /// logged at debug level and swallowed rather than failing the
984    /// otherwise-successful `put`. The file's own `sync_all` (issued
985    /// before the rename) is the load-bearing durability step on every
986    /// platform; the directory sync is the extra POSIX guarantee.
987    fn sync_dir_best_effort(&self) {
988        match File::open(&self.dir).and_then(|d| d.sync_all()) {
989            Ok(()) => {}
990            Err(e) => {
991                tracing::debug!(
992                    target: "tensor_wasm_artifacts",
993                    dir = %self.dir.display(),
994                    error = %e,
995                    "directory fsync skipped (best-effort; unsupported on this platform?)"
996                );
997            }
998        }
999    }
1000}
1001
1002/// Result of [`DiskArtifactStore::verify_blob`]: the authenticated header
1003/// hash and the zstd body length, handed to the decode pass.
1004struct VerifiedBlob {
1005    hash_on_disk: [u8; 32],
1006    body_len: u64,
1007}
1008
1009/// `Write` adapter that tees bytes into a BLAKE3 hasher (for the
1010/// defence-in-depth content-hash recheck), counts them, and forwards to a
1011/// downstream writer. Used by the streaming `get_to` decode pass so the
1012/// decoded bytes are hashed *as they stream to the caller* — no second
1013/// buffer.
1014///
1015/// A downstream write failure is recorded in `downstream_failed` and
1016/// surfaced as an `io::ErrorKind::Other` so the caller can map it to
1017/// [`ArtifactError::Io`] (and distinguish it from a zstd decode fault,
1018/// which `std::io::copy` reports with the same kind).
1019struct HashingWriter<'a> {
1020    inner: &'a mut dyn Write,
1021    hasher: blake3::Hasher,
1022    downstream_failed: bool,
1023}
1024
1025impl<'a> HashingWriter<'a> {
1026    fn new(inner: &'a mut dyn Write) -> Self {
1027        Self {
1028            inner,
1029            hasher: blake3::Hasher::new(),
1030            downstream_failed: false,
1031        }
1032    }
1033}
1034
1035impl Write for HashingWriter<'_> {
1036    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1037        // Forward first; only hash the bytes the downstream accepted so
1038        // the recomputed hash matches exactly what reached the caller.
1039        match self.inner.write(buf) {
1040            Ok(n) => {
1041                self.hasher.update(&buf[..n]);
1042                Ok(n)
1043            }
1044            Err(e) => {
1045                self.downstream_failed = true;
1046                Err(e)
1047            }
1048        }
1049    }
1050    fn flush(&mut self) -> std::io::Result<()> {
1051        self.inner.flush()
1052    }
1053}
1054
1055impl ArtifactStore for DiskArtifactStore {
1056    fn put(&self, payload: &[u8]) -> Result<ContentHash, ArtifactError> {
1057        // Reject oversized payloads BEFORE any allocation or I/O. An
1058        // attacker who can drive `put` could otherwise request an
1059        // allocation proportional to `payload.len()` and OOM the
1060        // process. The check is on the already-borrowed `payload`
1061        // slice — we do not materialise the caller's bytes ourselves —
1062        // so this is a pure refusal, not a second allocation.
1063        if payload.len() > MAX_PAYLOAD_LEN {
1064            warn!(
1065                target: "tensor_wasm_artifacts",
1066                actual = payload.len(),
1067                limit = MAX_PAYLOAD_LEN,
1068                "rejecting oversized payload"
1069            );
1070            return Err(ArtifactError::TooLarge {
1071                actual: payload.len(),
1072                limit: MAX_PAYLOAD_LEN,
1073            });
1074        }
1075        std::fs::create_dir_all(&self.dir).map_err(|e| {
1076            warn!(
1077                target: "tensor_wasm_artifacts",
1078                dir = %self.dir.display(),
1079                error = %e,
1080                "create_dir_all failed"
1081            );
1082            ArtifactError::Io
1083        })?;
1084        let hash = ContentHash::of(payload);
1085
1086        // T22 streaming write: header + zstd(body) + HMAC tag stream
1087        // directly through a buffered `MacWriter` into a `NamedTempFile`,
1088        // with no intermediate `Vec<u8>` for the framed envelope. The
1089        // HMAC sees the exact bytes that land on disk; the writer is
1090        // wrapped so every chunk also feeds `Mac::update` on its way
1091        // through. Result: peak heap during `put` is bounded by the
1092        // 64 KiB BufWriter slab plus zstd's internal window, regardless
1093        // of payload size.
1094        let final_path = self.path_for(&hash);
1095        let mut tmp = tempfile::NamedTempFile::new_in(&self.dir).map_err(|e| {
1096            warn!(target: "tensor_wasm_artifacts", error = %e, "tempfile create failed");
1097            ArtifactError::Io
1098        })?;
1099        let mut mac = new_mac(&self.hmac_key);
1100
1101        // Streaming sink composition:
1102        //   file <- BufWriter <- MacWriter (tees to MAC) <- zstd encoder
1103        //
1104        // The encoder writes compressed bytes into `tee`, which forks
1105        // each byte into the 64 KiB BufWriter (then the on-disk file)
1106        // AND the running HMAC. No materialised framed buffer.
1107        {
1108            let buf_writer = BufWriter::with_capacity(STREAM_BUF_LEN, tmp.as_file_mut());
1109            let mut tee = MacWriter::new(buf_writer, &mut mac);
1110
1111            // Header (magic || version || content_hash) is written
1112            // through the tee so the HMAC covers the same prefix the
1113            // old one-shot path did.
1114            tee.write_all(&ARTIFACT_MAGIC).map_err(|e| {
1115                warn!(target: "tensor_wasm_artifacts", error = %e, "header magic write failed");
1116                ArtifactError::Io
1117            })?;
1118            tee.write_all(&ARTIFACT_VERSION.to_le_bytes()).map_err(|e| {
1119                warn!(target: "tensor_wasm_artifacts", error = %e, "header version write failed");
1120                ArtifactError::Io
1121            })?;
1122            tee.write_all(&hash.0).map_err(|e| {
1123                warn!(target: "tensor_wasm_artifacts", error = %e, "header hash write failed");
1124                ArtifactError::Io
1125            })?;
1126
1127            // Compress the payload streaming-style through the encoder.
1128            // `zstd::stream::write::Encoder` consumes raw bytes and
1129            // emits compressed bytes downstream — those compressed bytes
1130            // pass through `tee`, so they're both written to disk AND
1131            // hashed into the MAC in one pass.
1132            let mut encoder = zstd::stream::write::Encoder::new(&mut tee, DEFAULT_ZSTD_LEVEL)
1133                .map_err(|e| {
1134                    warn!(target: "tensor_wasm_artifacts", error = %e, "zstd init failed");
1135                    ArtifactError::Io
1136                })?;
1137            encoder.write_all(payload).map_err(|e| {
1138                warn!(target: "tensor_wasm_artifacts", error = %e, "zstd write failed");
1139                ArtifactError::Io
1140            })?;
1141            // `finish()` flushes the zstd footer through the tee. After
1142            // this point the MAC has consumed exactly `magic || version
1143            // || content_hash || zstd_body` — byte-identical to what
1144            // the old buffered path used to hash.
1145            encoder.finish().map_err(|e| {
1146                warn!(target: "tensor_wasm_artifacts", error = %e, "zstd finish failed");
1147                ArtifactError::Io
1148            })?;
1149
1150            // Drop the BufWriter explicitly via the tee so its 64 KiB
1151            // slab is flushed BEFORE we append the HMAC tag below.
1152            // `BufWriter::drop` swallows flush errors, so call `flush`
1153            // explicitly first to surface any deferred write failure.
1154            tee.flush().map_err(|e| {
1155                warn!(target: "tensor_wasm_artifacts", error = %e, "buf flush failed");
1156                ArtifactError::Io
1157            })?;
1158            // `tee` (and the BufWriter inside it) goes out of scope here,
1159            // releasing the `&mut File` borrow so we can write the tag
1160            // directly to `tmp.as_file_mut()` below.
1161        }
1162
1163        // Finalise the MAC over `header || zstd_body` and append the
1164        // 32-byte tag. The tag is NOT fed back into the MAC (and indeed
1165        // bypasses the `MacWriter`); it's the trailer the `get` reader
1166        // strips before recomputing.
1167        let tag = finalize_into_tag(mac);
1168        tmp.as_file_mut().write_all(&tag).map_err(|e| {
1169            warn!(target: "tensor_wasm_artifacts", error = %e, "hmac tag write failed");
1170            ArtifactError::Io
1171        })?;
1172
1173        // Durability (crash-consistency fix): flush the temp file's data
1174        // to stable storage BEFORE the atomic rename. Without this
1175        // `sync_all`, a crash after `persist` could leave the renamed
1176        // file's directory entry pointing at data still sitting in the
1177        // page cache, so the blob the doc comments promise is durable
1178        // could come back truncated or zero-length on the next boot.
1179        tmp.as_file().sync_all().map_err(|e| {
1180            warn!(target: "tensor_wasm_artifacts", error = %e, "blob fsync (pre-persist) failed");
1181            ArtifactError::Io
1182        })?;
1183
1184        // Atomic publish: temp-then-rename in the same directory,
1185        // mirroring the JIT L2 disk-cache pattern so a partial write
1186        // can never leave a half-formed entry that a concurrent reader
1187        // trips over.
1188        tmp.persist(&final_path).map_err(|e| {
1189            // `tempfile::PersistError` wraps the underlying `io::Error`
1190            // plus the temp handle; the `Display` impl forwards to the
1191            // io error so we don't need to reach for the field.
1192            warn!(target: "tensor_wasm_artifacts", error = %e, "tempfile persist failed");
1193            ArtifactError::Io
1194        })?;
1195
1196        // Durability: fsync the containing directory so the rename itself
1197        // is persisted, not just the file data. On Windows a directory
1198        // `sync_all` typically fails (you cannot open a directory as a
1199        // syncable file handle); that is tolerated as best-effort — the
1200        // file data is already durable from the `sync_all` above, which
1201        // is the part that matters most for crash consistency.
1202        self.sync_dir_best_effort();
1203        Ok(hash)
1204    }
1205
1206    fn get(&self, hash: &ContentHash) -> Result<Vec<u8>, ArtifactError> {
1207        // Rotation-aware key resolution: keys partition the on-disk
1208        // namespace by fingerprint, so at most one accepted read key has a
1209        // file for this content hash. Probe each (active first) and use
1210        // whichever one's file exists. If none exist it's a genuine miss.
1211        let (path, key) = match self.resolve_read_key(hash)? {
1212            Some(found) => found,
1213            None => return Err(ArtifactError::NotFound(hash.to_string())),
1214        };
1215
1216        // T22 streaming read: open the file behind a 64 KiB BufReader
1217        // and stream the prefix (header + zstd body) through a
1218        // `MacReader` -> `zstd::Decoder` chain. The HMAC is built up as
1219        // bytes flow into the decoder, so we never materialise the
1220        // whole compressed body in RAM. Only the decoded payload is
1221        // buffered (capped at MAX_DECOMPRESSED_LEN), and even that is
1222        // only released to the caller AFTER the trailing tag verifies.
1223        let file = match File::open(&path) {
1224            Ok(f) => f,
1225            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1226                return Err(ArtifactError::NotFound(hash.to_string()));
1227            }
1228            Err(e) => {
1229                warn!(
1230                    target: "tensor_wasm_artifacts",
1231                    file = %path.display(),
1232                    error = %e,
1233                    "open failed"
1234                );
1235                return Err(ArtifactError::Io);
1236            }
1237        };
1238        let file_len = file
1239            .metadata()
1240            .map_err(|e| {
1241                warn!(
1242                    target: "tensor_wasm_artifacts",
1243                    file = %path.display(),
1244                    error = %e,
1245                    "metadata failed"
1246                );
1247                ArtifactError::Io
1248            })?
1249            .len();
1250
1251        // Minimum-length gate: header + at least one byte of zstd frame + HMAC.
1252        // Mirrors the old in-memory check; rejected here before we
1253        // start any keyed work or decoder setup.
1254        let min_len = (ARTIFACT_HEADER_LEN + ARTIFACT_HMAC_LEN) as u64;
1255        if file_len < min_len {
1256            warn!(
1257                target: "tensor_wasm_artifacts",
1258                file = %path.display(),
1259                len = file_len,
1260                "artifact too short for header+hmac"
1261            );
1262            return Err(ArtifactError::BadMagic);
1263        }
1264
1265        // Compute the byte ranges in the file:
1266        //   [0 .. ARTIFACT_HEADER_LEN)                — header
1267        //   [ARTIFACT_HEADER_LEN .. prefix_end)        — zstd body
1268        //   [prefix_end .. file_len)                   — HMAC tag (32 B)
1269        //
1270        // `prefix_end` is what the MAC must cover.
1271        let prefix_end = file_len - ARTIFACT_HMAC_LEN as u64;
1272        let body_len = prefix_end - ARTIFACT_HEADER_LEN as u64;
1273
1274        let mut reader = BufReader::with_capacity(STREAM_BUF_LEN, file);
1275        let mut mac = new_mac(&key);
1276
1277        // ---- Read and validate the fixed header. ----
1278        let mut header = [0u8; ARTIFACT_HEADER_LEN];
1279        reader.read_exact(&mut header).map_err(|e| {
1280            warn!(
1281                target: "tensor_wasm_artifacts",
1282                file = %path.display(),
1283                error = %e,
1284                "header read failed"
1285            );
1286            ArtifactError::Io
1287        })?;
1288        if header[..16] != ARTIFACT_MAGIC {
1289            return Err(ArtifactError::BadMagic);
1290        }
1291        let version = u32::from_le_bytes([header[16], header[17], header[18], header[19]]);
1292        if version != ARTIFACT_VERSION {
1293            return Err(ArtifactError::BadVersion(version));
1294        }
1295        let mut hash_on_disk = [0u8; 32];
1296        hash_on_disk.copy_from_slice(&header[20..52]);
1297        // Feed the header into the MAC — same prefix the writer hashed.
1298        {
1299            use hmac::Mac;
1300            mac.update(&header);
1301        }
1302
1303        // ---- Stream the zstd body through MacReader -> Decoder. ----
1304        //
1305        // `Read::take(body_len)` clips the source to exactly the zstd
1306        // body, so the decoder cannot accidentally read into the
1307        // trailing HMAC tag. The `MacReader` tees those same bytes into
1308        // the running HMAC, so by the time the decoder returns EOF we
1309        // have the MAC for the full prefix.
1310        //
1311        // The decoder output goes into another `Take(cap + 1)` so a
1312        // zstd-bomb cannot blow past `MAX_DECOMPRESSED_LEN`. Same
1313        // probe-by-one shape T10 uses on the snapshot reader.
1314        let cap = MAX_DECOMPRESSED_LEN;
1315        let probe_limit = u64::try_from(cap)
1316            .ok()
1317            .and_then(|c| c.checked_add(1))
1318            .unwrap_or(u64::MAX);
1319        // Scope the decoder/MacReader chain so it drops (releasing the
1320        // mutable borrows on `reader` and `mac`) before we drain any
1321        // residual body bytes and read the HMAC tag.
1322        //
1323        // Decoder failures are deferred: a tampered body byte will
1324        // typically make zstd return a frame-format error, but the
1325        // pre-existing contract is that an unauthenticated artifact
1326        // returns `BadHmac` — not `Decompression`. So we capture the
1327        // decode result here, drain the rest of the body through the
1328        // MAC so the running tag stays byte-aligned with the writer,
1329        // verify the HMAC, and only THEN surface the decode error.
1330        // That preserves the "BadHmac wins over Decompression on
1331        // tampered input" invariant the tamper-rejection tests assert.
1332        // Size the initial allocation from the compressed body length
1333        // clamped to the cap, then let the Vec grow on demand. PERF/
1334        // security: the old 4x multiplier reserved 256 MiB up front for a
1335        // 64 MiB compressed body — a large speculative allocation driven
1336        // by attacker-controlled body length. Use a conservative 2x
1337        // estimate (most tensor-memory payloads compress better than 2:1,
1338        // so this rarely under-reserves much) and rely on `Vec`'s
1339        // amortised growth for the incompressible tail.
1340        let initial_capacity = usize::try_from(body_len)
1341            .unwrap_or(cap)
1342            .saturating_mul(2)
1343            .min(cap);
1344        let mut payload: Vec<u8> = Vec::with_capacity(initial_capacity);
1345        let mut decode_result: Result<(), ArtifactError> = Ok(());
1346        {
1347            let body_take = Read::take(&mut reader, body_len);
1348            let mac_reader = MacReader::new(body_take, &mut mac);
1349            match zstd::stream::read::Decoder::new(mac_reader) {
1350                Ok(decoder) => {
1351                    if let Err(e) = decoder.take(probe_limit).read_to_end(&mut payload) {
1352                        warn!(target: "tensor_wasm_artifacts", error = %e, "zstd decode failed");
1353                        decode_result = Err(ArtifactError::Decompression(e.to_string()));
1354                    }
1355                }
1356                Err(e) => {
1357                    warn!(target: "tensor_wasm_artifacts", error = %e, "zstd init failed");
1358                    decode_result = Err(ArtifactError::Decompression(e.to_string()));
1359                }
1360            }
1361            // `decoder` (if it existed) was consumed by `.take(...)`,
1362            // which was a temporary consumed by `read_to_end`. The
1363            // MacReader/Take chain is gone now; the `&mut reader` and
1364            // `&mut mac` borrows are released at the end of this block.
1365        }
1366        // Whatever happened, the decoder/MacReader/Take chain is now
1367        // dropped — `mac` and `reader` are reborrowable below.
1368
1369        // ---- Drain any residual body bytes the decoder skipped. ----
1370        //
1371        // For a well-formed zstd frame the decoder consumes every body
1372        // byte (frames are self-delimiting and end at the body's
1373        // boundary, which we enforce via the `body_take` adapter). But
1374        // when decoding aborts early (tampered frame, truncated input)
1375        // the BufReader may sit somewhere inside the body, leaving the
1376        // HMAC's running state short of what the writer hashed.
1377        //
1378        // Drain whatever's left in the body region through a fresh
1379        // MacReader so the MAC sees the FULL body bytes — the same
1380        // prefix the writer's MAC covered. This is what lets a
1381        // decompression-failure-on-tamper still surface as `BadHmac`
1382        // instead of `Decompression`, matching the old buffered code's
1383        // failure-mode ordering.
1384        use std::io::Seek;
1385        let consumed = reader.stream_position().map_err(|e| {
1386            warn!(target: "tensor_wasm_artifacts", error = %e, "stream_position failed");
1387            ArtifactError::Io
1388        })?;
1389        if consumed < prefix_end {
1390            let gap = prefix_end - consumed;
1391            let drain_take = Read::take(&mut reader, gap);
1392            let mut drain_mac = MacReader::new(drain_take, &mut mac);
1393            // Discard the bytes — we only care about feeding the MAC.
1394            let mut scratch = [0u8; STREAM_BUF_LEN];
1395            loop {
1396                let n = drain_mac.read(&mut scratch).map_err(|e| {
1397                    warn!(target: "tensor_wasm_artifacts", error = %e, "tail drain failed");
1398                    ArtifactError::Io
1399                })?;
1400                if n == 0 {
1401                    break;
1402                }
1403            }
1404        } else if consumed > prefix_end {
1405            // Decoder over-read past the body's bound (shouldn't happen
1406            // because `body_take` caps it). Reposition so the next
1407            // read_exact pulls the tag; the MAC has over-counted and
1408            // will trip BadHmac below, which is the safe failure mode.
1409            reader
1410                .seek(std::io::SeekFrom::Start(prefix_end))
1411                .map_err(|e| {
1412                    warn!(target: "tensor_wasm_artifacts", error = %e, "seek to tag failed");
1413                    ArtifactError::Io
1414                })?;
1415        }
1416
1417        let mut tag_bytes = [0u8; ARTIFACT_HMAC_LEN];
1418        reader.read_exact(&mut tag_bytes).map_err(|e| {
1419            warn!(target: "tensor_wasm_artifacts", error = %e, "tag read failed");
1420            ArtifactError::Io
1421        })?;
1422
1423        let expected = finalize_into_tag(mac);
1424        use subtle::ConstantTimeEq;
1425        // `expected` is `[u8; 32]`; `ConstantTimeEq` is implemented on
1426        // `[u8]`. Slice-vs-slice keeps the comparison constant-time.
1427        let mac_ok = bool::from(expected.as_slice().ct_eq(&tag_bytes[..]));
1428        if !mac_ok {
1429            warn!(
1430                target: "tensor_wasm_artifacts",
1431                file = %path.display(),
1432                "HMAC mismatch (possible tampering or stale key)"
1433            );
1434            // CRITICAL: drop the decoded payload WITHOUT returning it —
1435            // a failed MAC means we have not authenticated the bytes
1436            // we just decompressed, and the existing invariant is
1437            // "HMAC verified BEFORE any decoded bytes are exposed to
1438            // callers". Returning here (with `payload` going out of
1439            // scope) preserves that invariant. BadHmac is also the
1440            // right answer when decode failed on a tampered body — the
1441            // old buffered path always returned BadHmac before reaching
1442            // the decompressor.
1443            return Err(ArtifactError::BadHmac);
1444        }
1445
1446        // MAC verified. Now surface any deferred decode error — a
1447        // legitimate decoder failure on an UNTAMPERED body (e.g. a zstd
1448        // version mismatch in a future migration) still reports as
1449        // `Decompression`, same as the old path.
1450        decode_result?;
1451
1452        // Decompressed-size cap check happens AFTER MAC verification so
1453        // a tampered payload can't make us return `TooLarge` before
1454        // `BadHmac`. The `Take(probe_limit)` adapter already prevented
1455        // any allocation past `cap + 1` bytes regardless.
1456        if payload.len() > cap {
1457            warn!(
1458                target: "tensor_wasm_artifacts",
1459                file = %path.display(),
1460                actual = payload.len(),
1461                limit = cap,
1462                "rejecting oversized decompressed payload (possible zstd bomb)"
1463            );
1464            return Err(ArtifactError::TooLarge {
1465                actual: payload.len(),
1466                limit: cap,
1467            });
1468        }
1469
1470        // Defence-in-depth: recompute the content hash from the
1471        // decoded payload and compare to both the requested key AND the
1472        // header value. A header-vs-payload mismatch would mean a
1473        // valid-HMAC blob was somehow constructed under a wrong content
1474        // hash; that's impossible with a non-leaked key, but cheap to
1475        // check.
1476        let recomputed = ContentHash::of(&payload);
1477        if recomputed.0 != hash_on_disk {
1478            return Err(ArtifactError::HashMismatch {
1479                expected: hex_of(&hash_on_disk),
1480                actual: recomputed.to_string(),
1481            });
1482        }
1483        if recomputed != *hash {
1484            return Err(ArtifactError::HashMismatch {
1485                expected: hash.to_string(),
1486                actual: recomputed.to_string(),
1487            });
1488        }
1489
1490        Ok(payload)
1491    }
1492
1493    fn get_to(&self, hash: &ContentHash, out: &mut dyn Write) -> Result<u64, ArtifactError> {
1494        // Two-pass streaming over a SINGLE open handle: the HMAC covers the
1495        // whole compressed blob, so we must authenticate before exposing any
1496        // decoded byte. Pass 1 (`verify_blob`) streams the compressed body
1497        // through the MAC and checks the tag WITHOUT decoding — no decoded
1498        // byte exists to leak. Pass 2 (`decode_to_writer`) then rewinds the
1499        // SAME handle and stream-decodes straight into `out`.
1500        //
1501        // TOCTOU fix: the file is opened exactly once and both passes read
1502        // that handle. The previous code re-opened the path for pass 2, so a
1503        // concurrent `remove`+`put` between the two opens could swap the
1504        // path's contents and pass 2 would decode bytes pass 1 never
1505        // HMAC-verified — weakening the doc-promised invariant that `out`
1506        // only ever sees authenticated bytes (the content-hash recheck
1507        // caught a swapped payload, but only after unverified bytes had
1508        // streamed out). Holding one handle pins the inode: an unlink+swap
1509        // unlinks the directory entry but our handle keeps reading the
1510        // original, authenticated bytes. We rewind with `seek(Start(0))`
1511        // between passes rather than re-opening.
1512        //
1513        // Tradeoff: this reads the compressed body off disk twice (re-read
1514        // via `seek`, not buffered in RAM). That keeps peak heap bounded by
1515        // the I/O buffers regardless of payload size — the alternative
1516        // (buffer the whole decoded payload, verify, then write) would hold
1517        // up to MAX_DECOMPRESSED_LEN resident, which is exactly what `get`
1518        // already does and what the streaming path exists to avoid.
1519        use std::io::{Seek, SeekFrom};
1520
1521        let (path, key) = match self.resolve_read_key(hash)? {
1522            Some(found) => found,
1523            None => return Err(ArtifactError::NotFound(hash.to_string())),
1524        };
1525
1526        // Open ONCE; both passes consume this handle.
1527        let file = match File::open(&path) {
1528            Ok(f) => f,
1529            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1530                // Raced an unlink between resolve and open: a genuine miss.
1531                return Err(ArtifactError::NotFound(hash.to_string()));
1532            }
1533            Err(e) => {
1534                warn!(target: "tensor_wasm_artifacts", file = %path.display(), error = %e, "open failed (get_to)");
1535                return Err(ArtifactError::Io);
1536            }
1537        };
1538        let file_len = file
1539            .metadata()
1540            .map_err(|e| {
1541                warn!(target: "tensor_wasm_artifacts", file = %path.display(), error = %e, "metadata failed (get_to)");
1542                ArtifactError::Io
1543            })?
1544            .len();
1545
1546        let mut reader = BufReader::with_capacity(STREAM_BUF_LEN, file);
1547
1548        // Pass 1: authenticate from this handle.
1549        let verified = self.verify_blob(&mut reader, file_len, &key, &path)?;
1550
1551        // Rewind the SAME handle so pass 2 decodes the exact bytes pass 1
1552        // authenticated — not whatever a racing writer may have swapped in.
1553        reader.seek(SeekFrom::Start(0)).map_err(|e| {
1554            warn!(target: "tensor_wasm_artifacts", file = %path.display(), error = %e, "rewind failed (get_to decode pass)");
1555            ArtifactError::Io
1556        })?;
1557
1558        // Pass 2: decode from the same (now-rewound) handle.
1559        self.decode_to_writer(&mut reader, &verified, hash, out, &path)
1560    }
1561
1562    fn list(&self) -> Result<Vec<ContentHash>, ArtifactError> {
1563        // Rotation-aware enumeration: union the hashes visible under every
1564        // accepted read-key fingerprint so an audit spans the rotation
1565        // boundary. Dedup because a content hash may be present under more
1566        // than one key (e.g. re-put after rotation). Probe each key's
1567        // `.bin` suffix in turn.
1568        let mut seen: std::collections::HashSet<ContentHash> = std::collections::HashSet::new();
1569        let mut fps: Vec<String> = self
1570            .key_provider
1571            .read_keys()
1572            .iter()
1573            .map(key_fingerprint_hex)
1574            .collect();
1575        // The single-key common case has exactly one fingerprint; dedup the
1576        // fingerprint list so a provider that repeats the active key in
1577        // `read_keys` doesn't scan the directory twice.
1578        fps.sort();
1579        fps.dedup();
1580        for fp in fps {
1581            self.list_one_key(&fp, &mut seen)?;
1582        }
1583        Ok(seen.into_iter().collect())
1584    }
1585
1586    fn contains(&self, hash: &ContentHash) -> Result<bool, ArtifactError> {
1587        // Rotation-aware stat-only existence probe: no open, no decode, no
1588        // HMAC. Resolves against every accepted read key (active first) with
1589        // the same [`Self::resolve_read_key`] probe `get` / `list` use, so a
1590        // blob written under a now-retired key reports `true` (the old code
1591        // checked only the active key, so such a blob was visible to `get`
1592        // yet reported absent here — a GC-leak hazard). `resolve_read_key`
1593        // returns `Ok(None)` on a genuine miss across all keys and
1594        // [`ArtifactError::Io`] on a probe fault that is neither present nor
1595        // absent.
1596        Ok(self.resolve_read_key(hash)?.is_some())
1597    }
1598
1599    fn remove(&self, hash: &ContentHash) -> Result<bool, ArtifactError> {
1600        // Rotation-aware unlink: resolve which accepted key (active or
1601        // retired) actually holds the blob with the same
1602        // [`Self::resolve_read_key`] probe `get` / `list` use, then unlink
1603        // under THAT key. The old code unlinked only under the active key,
1604        // so a blob written under a now-retired key was visible to `get` but
1605        // could never be removed here — a GC leak that contradicted the
1606        // rotation story. A genuine miss across all keys returns `false`,
1607        // not an error — this matches `HashMap::remove`'s boolean and lets
1608        // idempotent GC retries stay quiet.
1609        let (path, key) = match self.resolve_read_key(hash)? {
1610            Some(found) => found,
1611            None => return Ok(false),
1612        };
1613        let removed = match std::fs::remove_file(&path) {
1614            Ok(()) => true,
1615            // Raced a concurrent remove between resolve and unlink: the
1616            // entry is already gone, which is the same "nothing removed"
1617            // outcome as a genuine miss.
1618            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1619            Err(e) => {
1620                warn!(
1621                    target: "tensor_wasm_artifacts",
1622                    file = %path.display(),
1623                    error = %e,
1624                    "remove unlink failed"
1625                );
1626                return Err(ArtifactError::Io);
1627            }
1628        };
1629        // Best-effort: drop the metadata sidecar too so it never outlives
1630        // the blob it describes. The sidecar is partitioned under the SAME
1631        // key fingerprint as the blob, so resolve its path under the key we
1632        // just unlinked. A missing sidecar is the common case (plain `put`
1633        // writes none); any other unlink fault is logged but does not flip
1634        // the blob-removal result, since the blob — the authoritative entry
1635        // — is already gone.
1636        let fp = key_fingerprint_hex(&key);
1637        let meta_path = self.meta_path_for_key(hash, &fp);
1638        if let Err(e) = std::fs::remove_file(&meta_path) {
1639            if e.kind() != std::io::ErrorKind::NotFound {
1640                warn!(
1641                    target: "tensor_wasm_artifacts",
1642                    file = %meta_path.display(),
1643                    error = %e,
1644                    "remove sidecar unlink failed (blob already removed)"
1645                );
1646            }
1647        }
1648        Ok(removed)
1649    }
1650}
1651
1652impl DiskArtifactStore {
1653    /// Enumerate the content hashes stored under the single key fingerprint
1654    /// `fp`, inserting each into `seen`. Shared by [`ArtifactStore::list`]'s
1655    /// per-key loop. A missing store directory is treated as empty (the dir
1656    /// is created lazily on the first `put`); any other `read_dir` fault is
1657    /// propagated as [`ArtifactError::Io`].
1658    fn list_one_key(
1659        &self,
1660        fp: &str,
1661        seen: &mut std::collections::HashSet<ContentHash>,
1662    ) -> Result<(), ArtifactError> {
1663        let suffix = format!(".{fp}.bin");
1664        let entries = match std::fs::read_dir(&self.dir) {
1665            Ok(e) => e,
1666            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1667            Err(e) => {
1668                warn!(
1669                    target: "tensor_wasm_artifacts",
1670                    dir = %self.dir.display(),
1671                    error = %e,
1672                    "read_dir failed"
1673                );
1674                return Err(ArtifactError::Io);
1675            }
1676        };
1677        for entry in entries {
1678            // A per-entry error (e.g. the directory was racing with a
1679            // concurrent unlink, or an underlying I/O fault) is a real
1680            // enumeration failure, not a skippable filename mismatch —
1681            // propagate it rather than silently shortening the listing.
1682            let entry = entry.map_err(|e| {
1683                warn!(
1684                    target: "tensor_wasm_artifacts",
1685                    dir = %self.dir.display(),
1686                    error = %e,
1687                    "read_dir entry failed"
1688                );
1689                ArtifactError::Io
1690            })?;
1691            let name = match entry.file_name().into_string() {
1692                Ok(n) => n,
1693                Err(_) => continue,
1694            };
1695            // Filenames look like `{64 hex chars}.{16 hex chars}.bin`.
1696            // Match the suffix so we ignore files written under a
1697            // different key (and the `.meta.json` sidecars).
1698            if !name.ends_with(&suffix) {
1699                continue;
1700            }
1701            let hash_hex = &name[..name.len() - suffix.len()];
1702            if hash_hex.len() != 64 {
1703                continue;
1704            }
1705            let mut bytes = [0u8; 32];
1706            let mut ok = true;
1707            for (i, chunk) in hash_hex.as_bytes().chunks(2).enumerate() {
1708                let s = match std::str::from_utf8(chunk) {
1709                    Ok(s) => s,
1710                    Err(_) => {
1711                        ok = false;
1712                        break;
1713                    }
1714                };
1715                match u8::from_str_radix(s, 16) {
1716                    Ok(b) => bytes[i] = b,
1717                    Err(_) => {
1718                        ok = false;
1719                        break;
1720                    }
1721                }
1722            }
1723            if ok {
1724                seen.insert(ContentHash::from_bytes(bytes));
1725            }
1726        }
1727        Ok(())
1728    }
1729}
1730
1731fn hex_of(bytes: &[u8; 32]) -> String {
1732    bytes.iter().map(|b| format!("{:02x}", b)).collect()
1733}
1734
1735// =====================================================================
1736// In-memory envelope encode/decode (T40 — snapshot v0.4 default flip)
1737// =====================================================================
1738//
1739// The streaming disk-store paths above own the canonical encode/decode
1740// loop, but the snapshot crate's default `SnapshotWriter::capture` /
1741// `SnapshotReader::restore` deal in `Vec<u8>` (not a `&DiskArtifactStore`),
1742// so they need a pure-bytes door into the same envelope. These two
1743// helpers expose exactly that: a `Vec<u8>`-in / `Vec<u8>`-out pair that
1744// produces and consumes bytes byte-identical to what `DiskArtifactStore`
1745// would write to disk.
1746//
1747// They are intentionally NOT `pub` on the `ArtifactStore` trait — the
1748// trait abstracts over storage *backends*; these helpers are a framing
1749// utility. Callers who want a persistent store should still go through
1750// `DiskArtifactStore::put` / `get`; callers who need the framed bytes
1751// in memory (e.g. to bundle inside another envelope, or to attach to
1752// an HTTP body) use these.
1753
1754/// Encode `payload` into the unified artifact-store envelope (v0.4
1755/// snapshot default).
1756///
1757/// Returns `Vec<u8>` containing the byte sequence
1758/// `ARTIFACT_MAGIC || ARTIFACT_VERSION || blake3(payload) || zstd(payload) || hmac_sha256(prefix)` —
1759/// the same bytes [`DiskArtifactStore::put`] would write to its tempfile
1760/// before atomic-rename. Useful for callers that need the framed envelope
1761/// in memory (the snapshot crate's default `capture` path is the
1762/// motivating consumer).
1763///
1764/// The HMAC covers `magic || version || content_hash || zstd(payload)`;
1765/// the trailing 32-byte tag is appended after. Verification is the
1766/// counterpart [`decode_envelope_from_bytes`], which recomputes the MAC
1767/// in constant time and rejects on mismatch before any decoded bytes
1768/// are exposed to the caller.
1769///
1770/// Errors are reported via [`ArtifactError`] to keep the error surface
1771/// homogeneous with the disk store; the only failure modes are
1772/// `TooLarge` (when `payload.len() > MAX_PAYLOAD_LEN`) and `Io` (when
1773/// the in-memory zstd encoder reports an internal error).
1774pub fn encode_envelope_to_vec(
1775    payload: &[u8],
1776    hmac_key: &[u8; 32],
1777) -> Result<Vec<u8>, ArtifactError> {
1778    if payload.len() > MAX_PAYLOAD_LEN {
1779        warn!(
1780            target: "tensor_wasm_artifacts",
1781            actual = payload.len(),
1782            limit = MAX_PAYLOAD_LEN,
1783            "rejecting oversized payload (encode_envelope_to_vec)"
1784        );
1785        return Err(ArtifactError::TooLarge {
1786            actual: payload.len(),
1787            limit: MAX_PAYLOAD_LEN,
1788        });
1789    }
1790
1791    let hash = ContentHash::of(payload);
1792    let mut mac = new_mac(hmac_key);
1793
1794    // Pre-size the framing buffer conservatively: header + a quarter of
1795    // the payload (zstd typically compresses better than 4:1 on tensor
1796    // memory, so this overshoots harmlessly for small inputs and
1797    // undershoots only marginally on incompressible blobs) + the HMAC
1798    // trailer. The Vec grows on demand if the estimate is too small.
1799    let mut buf: Vec<u8> =
1800        Vec::with_capacity(ARTIFACT_HEADER_LEN + payload.len() / 4 + ARTIFACT_HMAC_LEN);
1801
1802    // Header: 16-byte magic + 4-byte version + 32-byte content hash.
1803    buf.extend_from_slice(&ARTIFACT_MAGIC);
1804    buf.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes());
1805    buf.extend_from_slice(&hash.0);
1806
1807    // Stream zstd into the buffer through a MacWriter so the HMAC sees
1808    // exactly the bytes we write. The MAC has already consumed the
1809    // header by way of the explicit `mac.update`s above? No — the
1810    // header was extended into `buf` directly (no tee). Feed it to
1811    // the MAC explicitly here so the prefix the MAC covers matches the
1812    // disk-store layout byte-for-byte (`header || zstd_body`).
1813    {
1814        use hmac::Mac;
1815        mac.update(&buf[..ARTIFACT_HEADER_LEN]);
1816    }
1817
1818    // Compress the payload into the buffer; tee through MacWriter so the
1819    // MAC also consumes the compressed bytes. The MacWriter takes the
1820    // buffer by mutable reference (Vec<u8> implements Write via
1821    // `extend_from_slice`-flavoured semantics), so the resulting bytes
1822    // continue to land in `buf` while the MAC observes the same
1823    // sequence the disk-store writer would see.
1824    {
1825        let mut tee = MacWriter::new(&mut buf, &mut mac);
1826        let mut encoder = zstd::stream::write::Encoder::new(&mut tee, DEFAULT_ZSTD_LEVEL)
1827            .map_err(|e| {
1828                warn!(target: "tensor_wasm_artifacts", error = %e, "zstd init failed (encode_envelope_to_vec)");
1829                ArtifactError::Io
1830            })?;
1831        encoder.write_all(payload).map_err(|e| {
1832            warn!(target: "tensor_wasm_artifacts", error = %e, "zstd write failed (encode_envelope_to_vec)");
1833            ArtifactError::Io
1834        })?;
1835        encoder.finish().map_err(|e| {
1836            warn!(target: "tensor_wasm_artifacts", error = %e, "zstd finish failed (encode_envelope_to_vec)");
1837            ArtifactError::Io
1838        })?;
1839    }
1840
1841    // Append the HMAC tag. The tag is NOT fed back into the MAC.
1842    let tag = finalize_into_tag(mac);
1843    buf.extend_from_slice(&tag);
1844    Ok(buf)
1845}
1846
1847/// Decode the unified artifact-store envelope from `bytes`, returning
1848/// the inner payload after verifying the HMAC in constant time and the
1849/// BLAKE3 content hash as defence-in-depth.
1850///
1851/// Counterpart to [`encode_envelope_to_vec`]. Used by the snapshot
1852/// crate's default `SnapshotReader::restore` to detect and consume the
1853/// v0.4 envelope before falling through to the legacy v3 / v2 readers.
1854/// Returns [`ArtifactError::BadMagic`] if the leading 16 bytes do not
1855/// match [`ARTIFACT_MAGIC`] — callers rely on that variant to know they
1856/// should try a different envelope shape, so the magic check is the
1857/// first thing this function does (cheap, allocation-free).
1858///
1859/// Validation order:
1860/// 1. Minimum length, then magic and version (cheap, before any keyed
1861///    work).
1862/// 2. HMAC verification in constant time over `magic || version ||
1863///    content_hash || zstd_body`. Failure returns [`ArtifactError::BadHmac`]
1864///    without touching the decoded payload.
1865/// 3. zstd decompression with a [`MAX_DECOMPRESSED_LEN`] cap, mirroring
1866///    the disk store's zip-bomb defence.
1867/// 4. Recompute the content hash and compare to the header value —
1868///    catches a writer bug that hashed the wrong bytes even if the
1869///    HMAC verified (impossible without key leak, but cheap to check).
1870pub fn decode_envelope_from_bytes(
1871    bytes: &[u8],
1872    hmac_key: &[u8; 32],
1873) -> Result<Vec<u8>, ArtifactError> {
1874    decode_envelope_from_bytes_with_cap(bytes, hmac_key, MAX_DECOMPRESSED_LEN)
1875}
1876
1877/// Decode the unified artifact-store envelope from `bytes`, like
1878/// [`decode_envelope_from_bytes`], but with a caller-supplied
1879/// `max_decompressed` ceiling instead of the crate-wide
1880/// [`MAX_DECOMPRESSED_LEN`].
1881///
1882/// This exists so a consumer with its own decompressed-size budget — the
1883/// snapshot reader's `with_max_decompressed` knob is the motivating
1884/// case — can enforce a tighter (or looser) zip-bomb cap than the
1885/// default without round-tripping through the disk store. `decode_envelope_from_bytes`
1886/// is a thin wrapper that calls this with `max_decompressed =
1887/// MAX_DECOMPRESSED_LEN`.
1888///
1889/// Behaviour, validation order, and error surface are otherwise
1890/// identical to [`decode_envelope_from_bytes`]: the only difference is
1891/// which ceiling drives the `Take` probe and the post-decode
1892/// [`ArtifactError::TooLarge`] check.
1893pub fn decode_envelope_from_bytes_with_cap(
1894    bytes: &[u8],
1895    hmac_key: &[u8; 32],
1896    max_decompressed: usize,
1897) -> Result<Vec<u8>, ArtifactError> {
1898    // Minimum-length gate: header + at least one byte of zstd frame + HMAC tag.
1899    if bytes.len() < ARTIFACT_HEADER_LEN + ARTIFACT_HMAC_LEN {
1900        return Err(ArtifactError::BadMagic);
1901    }
1902    // Magic check first — cheap rejection for foreign envelopes (the
1903    // snapshot reader uses this branch to fall through to v3 / v2).
1904    if bytes[..16] != ARTIFACT_MAGIC {
1905        return Err(ArtifactError::BadMagic);
1906    }
1907    let version = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
1908    if version != ARTIFACT_VERSION {
1909        return Err(ArtifactError::BadVersion(version));
1910    }
1911    let mut hash_on_disk = [0u8; 32];
1912    hash_on_disk.copy_from_slice(&bytes[20..52]);
1913
1914    // HMAC verification: the tag is the trailing 32 bytes, the MAC
1915    // covers everything before that (header + zstd body).
1916    let hmac_start = bytes.len() - ARTIFACT_HMAC_LEN;
1917    let (prefix, tag_bytes) = bytes.split_at(hmac_start);
1918    let mut mac = new_mac(hmac_key);
1919    {
1920        use hmac::Mac;
1921        mac.update(prefix);
1922    }
1923    let expected = finalize_into_tag(mac);
1924    use subtle::ConstantTimeEq;
1925    let mac_ok = bool::from(expected.as_slice().ct_eq(tag_bytes));
1926    if !mac_ok {
1927        warn!(
1928            target: "tensor_wasm_artifacts",
1929            "HMAC mismatch (decode_envelope_from_bytes; possible tampering or stale key)"
1930        );
1931        return Err(ArtifactError::BadHmac);
1932    }
1933
1934    // Decompress the body that sits between the header and the HMAC tag.
1935    // Use a `Read::take` probe one byte past the cap so a zstd-bomb is
1936    // rejected before the buffer grows past `MAX_DECOMPRESSED_LEN`,
1937    // matching the disk-store streaming reader.
1938    let body = &prefix[ARTIFACT_HEADER_LEN..];
1939    let cap = max_decompressed;
1940    let probe_limit = u64::try_from(cap)
1941        .ok()
1942        .and_then(|c| c.checked_add(1))
1943        .unwrap_or(u64::MAX);
1944    // Size the initial allocation from the compressed body length,
1945    // clamped to the cap, and let the Vec grow on demand. PERF/security:
1946    // the old 4x multiplier reserved up to 4x the compressed body up
1947    // front (256 MiB for a 64 MiB body) on an attacker-controlled
1948    // length. A conservative 2x estimate covers typical tensor-memory
1949    // payloads while `Vec`'s amortised growth handles the rest.
1950    let initial_capacity = body.len().saturating_mul(2).min(cap);
1951    let mut payload: Vec<u8> = Vec::with_capacity(initial_capacity);
1952    let decoder = zstd::stream::read::Decoder::new(body).map_err(|e| {
1953        warn!(target: "tensor_wasm_artifacts", error = %e, "zstd init failed (decode_envelope_from_bytes)");
1954        ArtifactError::Decompression(e.to_string())
1955    })?;
1956    decoder
1957        .take(probe_limit)
1958        .read_to_end(&mut payload)
1959        .map_err(|e| {
1960            warn!(target: "tensor_wasm_artifacts", error = %e, "zstd decode failed (decode_envelope_from_bytes)");
1961            ArtifactError::Decompression(e.to_string())
1962        })?;
1963    if payload.len() > cap {
1964        return Err(ArtifactError::TooLarge {
1965            actual: payload.len(),
1966            limit: cap,
1967        });
1968    }
1969
1970    // Defence-in-depth: recompute and compare. A header-vs-payload
1971    // mismatch would mean a valid-HMAC blob was constructed under a
1972    // wrong content hash (impossible without key leak, but cheap).
1973    let recomputed = ContentHash::of(&payload);
1974    if recomputed.0 != hash_on_disk {
1975        return Err(ArtifactError::HashMismatch {
1976            expected: hex_of(&hash_on_disk),
1977            actual: recomputed.to_string(),
1978        });
1979    }
1980
1981    Ok(payload)
1982}
1983
1984// =====================================================================
1985// In-memory store
1986// =====================================================================
1987
1988/// In-memory artifact store. Intended for tests, fuzzers, and ephemeral
1989/// caches.
1990///
1991/// # SECURITY WARNING: NO integrity or signature verification
1992///
1993/// Unlike [`DiskArtifactStore`], this store performs **NO HMAC signing
1994/// and NO HMAC/content-hash verification** on `put` / `get`. It stores
1995/// the plaintext payload in a `HashMap` and hands it straight back. The
1996/// `hmac_key` below is accepted only so the constructor signature
1997/// matches the disk store's; it is never used to sign or verify anything
1998/// (hence `#[allow(dead_code)]`).
1999///
2000/// This is safe **only** because the in-memory map is the trust boundary:
2001/// the bytes returned are exactly the bytes a (trusted) caller inserted
2002/// in the same process, so there is no untrusted on-the-wire/on-disk
2003/// envelope to authenticate. Do **NOT** use this type to back any data
2004/// path that crosses a trust boundary (untrusted input, persistence,
2005/// IPC, network). For anything that must detect tampering or forged
2006/// blobs, use [`DiskArtifactStore`], which signs and constant-time
2007/// verifies every record. Treat this store as test/ephemeral-only.
2008pub struct InMemoryArtifactStore {
2009    entries: parking_lot::Mutex<HashMap<ContentHash, Vec<u8>>>,
2010    // Held for parity with `DiskArtifactStore::hmac_key` (and zeroized on
2011    // drop), but DELIBERATELY never read: this store does no signing or
2012    // verification (see the type-level SECURITY WARNING). Not read on any
2013    // path. Future migrations (snapshot replay-protection matrix,
2014    // signed-kernel-registry) may want to surface the key fingerprint
2015    // from an in-memory store too.
2016    #[allow(dead_code)]
2017    hmac_key: Zeroizing<[u8; 32]>,
2018}
2019
2020impl InMemoryArtifactStore {
2021    /// Construct an empty in-memory store under `hmac_key`.
2022    pub fn new(hmac_key: [u8; 32]) -> Self {
2023        Self {
2024            entries: parking_lot::Mutex::new(HashMap::new()),
2025            hmac_key: Zeroizing::new(hmac_key),
2026        }
2027    }
2028}
2029
2030impl ArtifactStore for InMemoryArtifactStore {
2031    fn put(&self, payload: &[u8]) -> Result<ContentHash, ArtifactError> {
2032        let hash = ContentHash::of(payload);
2033        self.entries.lock().insert(hash, payload.to_vec());
2034        Ok(hash)
2035    }
2036    fn get(&self, hash: &ContentHash) -> Result<Vec<u8>, ArtifactError> {
2037        self.entries
2038            .lock()
2039            .get(hash)
2040            .cloned()
2041            .ok_or_else(|| ArtifactError::NotFound(hash.to_string()))
2042    }
2043    fn get_to(&self, hash: &ContentHash, out: &mut dyn Write) -> Result<u64, ArtifactError> {
2044        // The in-memory map already holds the plaintext payload (integrity
2045        // is guaranteed by the map itself — no envelope to verify), so
2046        // streaming is a single write of the stored bytes while the lock
2047        // is held. We write under the lock to avoid cloning the payload
2048        // first; the bytes are authentic by construction.
2049        let guard = self.entries.lock();
2050        let bytes = guard
2051            .get(hash)
2052            .ok_or_else(|| ArtifactError::NotFound(hash.to_string()))?;
2053        out.write_all(bytes).map_err(|e| {
2054            warn!(target: "tensor_wasm_artifacts", error = %e, "in-memory get_to writer write failed");
2055            ArtifactError::Io
2056        })?;
2057        Ok(bytes.len() as u64)
2058    }
2059    fn list(&self) -> Result<Vec<ContentHash>, ArtifactError> {
2060        // An in-memory map cannot fail to enumerate, so this is
2061        // infallible — but the signature matches the trait so callers
2062        // treat both stores uniformly.
2063        Ok(self.entries.lock().keys().copied().collect())
2064    }
2065    fn contains(&self, hash: &ContentHash) -> Result<bool, ArtifactError> {
2066        // `contains_key` is the cheap probe — no clone of the payload,
2067        // matching the disk store's stat-only contract. Infallible for
2068        // an in-memory map; wrapped in `Ok` for trait uniformity.
2069        Ok(self.entries.lock().contains_key(hash))
2070    }
2071    fn remove(&self, hash: &ContentHash) -> Result<bool, ArtifactError> {
2072        // `HashMap::remove` returns the old value; map it to the
2073        // trait's "was something removed?" boolean.
2074        Ok(self.entries.lock().remove(hash).is_some())
2075    }
2076}
2077
2078#[cfg(test)]
2079mod tests {
2080    use super::*;
2081
2082    #[test]
2083    fn content_hash_is_blake3() {
2084        let h = ContentHash::of(b"hello");
2085        let expected: [u8; 32] = blake3::hash(b"hello").into();
2086        assert_eq!(h.0, expected);
2087        assert_eq!(h.to_hex().len(), 64);
2088    }
2089
2090    #[test]
2091    fn in_memory_put_get_round_trip() {
2092        let store = InMemoryArtifactStore::new([7u8; 32]);
2093        let hash = store.put(b"payload").unwrap();
2094        assert_eq!(store.get(&hash).unwrap(), b"payload");
2095        assert_eq!(store.list().unwrap().len(), 1);
2096    }
2097
2098    #[test]
2099    fn in_memory_contains_and_remove() {
2100        let store = InMemoryArtifactStore::new([9u8; 32]);
2101        let hash = store.put(b"x").unwrap();
2102        assert!(store.contains(&hash).unwrap());
2103        assert!(store.remove(&hash).unwrap(), "first remove deletes");
2104        assert!(!store.contains(&hash).unwrap());
2105        assert!(!store.remove(&hash).unwrap(), "second remove is a no-op");
2106    }
2107
2108    #[test]
2109    fn in_memory_get_to_streams_payload() {
2110        let store = InMemoryArtifactStore::new([5u8; 32]);
2111        let hash = store.put(b"streamed").unwrap();
2112        let mut out: Vec<u8> = Vec::new();
2113        let n = store.get_to(&hash, &mut out).unwrap();
2114        assert_eq!(n, 8);
2115        assert_eq!(out, b"streamed");
2116    }
2117
2118    #[test]
2119    fn single_key_provider_active_is_only_read_key() {
2120        let p = SingleKeyProvider::new([3u8; 32]);
2121        assert_eq!(p.active_key(), [3u8; 32]);
2122        assert_eq!(p.read_keys(), vec![[3u8; 32]]);
2123    }
2124
2125    #[test]
2126    fn rotating_provider_active_first_then_accepted() {
2127        let p = RotatingKeyProvider::new([1u8; 32], [[2u8; 32], [3u8; 32]]);
2128        assert_eq!(p.active_key(), [1u8; 32]);
2129        // Active key leads, then the accepted-for-read keys in order.
2130        assert_eq!(p.read_keys(), vec![[1u8; 32], [2u8; 32], [3u8; 32]]);
2131    }
2132
2133    #[test]
2134    fn metadata_serde_round_trips() {
2135        let m = ArtifactMetadata {
2136            created_unix_ms: 123,
2137            original_len: 456,
2138            source_tier: "test".to_string(),
2139        };
2140        let json = serde_json::to_vec(&m).unwrap();
2141        let back: ArtifactMetadata = serde_json::from_slice(&json).unwrap();
2142        assert_eq!(m, back);
2143    }
2144
2145    #[test]
2146    fn key_fingerprint_is_8_bytes_hex() {
2147        let fp = key_fingerprint_hex(&[0u8; 32]);
2148        assert_eq!(fp.len(), 16); // 8 bytes -> 16 hex chars
2149    }
2150
2151    #[test]
2152    fn disk_format_constants() {
2153        assert_eq!(ARTIFACT_HEADER_LEN, 16 + 4 + 32);
2154        assert_eq!(ARTIFACT_HMAC_LEN, 32);
2155        assert_eq!(ARTIFACT_MAGIC.len(), 16);
2156    }
2157}