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}