Skip to main content

tensor_wasm_jit/
registry.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Signed kernel registry (roadmap feature #3).
5//!
6//! Operators publish vetted PTX kernels (matmul, attention, conv2d) as
7//! HMAC-SHA256-signed [`KernelManifest`] records. Guests reference kernels
8//! by `name@version` (or content-addressed digest); the runtime resolves
9//! the manifest, verifies the signature, and exposes the PTX text to
10//! the JIT cache as a pre-populated entry.
11//!
12//! ## v0.3.7 status: scaffold
13//!
14//! Manifest types, signing helpers, and the registry trait surface land.
15//! Actual on-disk store, signing CLI, and wire-format integration are
16//! v0.4 deliverables — see `docs/KERNEL-REGISTRY.md`.
17//!
18//! ## Signing envelope (v2)
19//!
20//! The HMAC-SHA256 input is the byte concatenation
21//!
22//! ```text
23//! "twasm-kmf-v2"                                 (12 bytes, magic + version tag)
24//! u64_le(name.len())      || name.as_bytes()
25//! u64_le(version.len())   || version.as_bytes()
26//! u64_le(publisher.len()) || publisher.as_bytes()
27//! u64_le(8)               || published_unix_ms as u64_le
28//! u64_le(4)               || sm_version as u32_le
29//! u64_le(digest.len())    || digest_bytes
30//! ```
31//!
32//! where `digest` is the BLAKE3 hash of the UTF-8 PTX text. Every
33//! field is preceded by a `u64`-little-endian length prefix. Fixed-
34//! width integer fields (`published_unix_ms`, `sm_version`) carry a
35//! length prefix too, so the canonical encoding is trivially
36//! parseable end-to-end and uniform across all fields.
37//!
38//! Length-prefixing replaces the prior NUL-separator scheme — under
39//! NUL separators the two manifests `("a\0b", "c", ...)` and
40//! `("a", "b\0c", ...)` produced identical signed-byte streams, a
41//! cross-field collision that an attacker with publish access could
42//! exploit. Length prefixes make field boundaries unambiguous and
43//! also bind the `publisher` and `published_unix_ms` fields into the
44//! MAC so they can no longer be rewritten post-sign without
45//! invalidating the signature.
46//!
47//! The leading `b"twasm-kmf-v2"` magic gates this envelope to v2.
48//! Manifests signed with the v0.3.7 (v1) NUL-separator scheme will
49//! NOT verify under the v2 MAC and must be re-signed — see the
50//! `[Unreleased]` CHANGELOG entry.
51
52use serde::{Deserialize, Serialize};
53use std::collections::{HashMap, HashSet};
54use std::path::PathBuf;
55use std::sync::Arc;
56
57use dashmap::DashMap;
58use tensor_wasm_artifacts::{ArtifactError, ArtifactStore, ContentHash, DiskArtifactStore};
59
60/// Signed kernel manifest. Cargo for vetted PTX.
61///
62/// Marked `#[non_exhaustive]` so future revisions can add fields
63/// without breaking downstream pattern-matching consumers. Construct
64/// instances via [`KernelManifest::new`] from outside this crate (the
65/// `non_exhaustive` attribute disallows struct-literal construction
66/// from foreign crates).
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[non_exhaustive]
69pub struct KernelManifest {
70    /// Stable name (e.g. `"matmul.f32"`).
71    pub name: String,
72    /// SemVer-style version (`"1.0.0"`).
73    pub version: String,
74    /// Compute capability the PTX was built for (e.g. `80` for sm_80).
75    pub sm_version: u32,
76    /// BLAKE3 hash of the PTX text. Content-addresses the artifact.
77    pub digest: [u8; 32],
78    /// HMAC-SHA256 tag computed over the canonical signed-bytes of
79    /// this manifest — see the module-level docstring for the exact
80    /// envelope layout (v2). Covers `name`, `version`, `publisher`,
81    /// `published_unix_ms`, `sm_version`, and `digest`.
82    ///
83    /// **The tag is PUBLIC by design.** It authenticates the
84    /// manifest's authorship (i.e. proves the holder of the publishing
85    /// HMAC key produced it) but is NOT a private signature in the
86    /// asymmetric-crypto sense and MUST NOT be treated as a secret.
87    /// `KernelManifest` deliberately derives `Serialize` / `Deserialize`
88    /// and the field is `pub` so the tag travels with the manifest
89    /// over the wire and through storage — that is the intended,
90    /// audited behaviour, not an oversight.
91    ///
92    /// What IS a secret is the HMAC key used to compute the tag (the
93    /// publisher's signing key); see `sign_manifest`. Leaking the
94    /// signing key — not the tag — is what would let an attacker
95    /// forge manifests.
96    pub signature: [u8; 32],
97    /// Wall-clock publish timestamp (Unix millis). Covered by the v2
98    /// signature envelope — tampering with this field invalidates the
99    /// signature.
100    pub published_unix_ms: u64,
101    /// Publisher identifier (typically a tenant id or signing-key id).
102    /// Covered by the v2 signature envelope — tampering with this
103    /// field invalidates the signature.
104    pub publisher: String,
105    /// Optional launch geometry hint `(grid_size, block_size)` the
106    /// kernel expects, mirroring [`EmittedPtx::launch_geometry`] on the
107    /// freshly-emitted path. Used by the JIT L3 registry-promotion path
108    /// (`KernelCache::get_with_registry_fallback`) to populate the
109    /// promoted L1 entry's geometry instead of falling back to `(0, 0)`.
110    ///
111    /// **Deliberately NOT covered by the v2 signature envelope.**
112    /// Launch geometry is an advisory *launch hint*, not a security
113    /// boundary — the PTX content itself is authenticated by `digest`
114    /// (and transitively by the signature, which binds `digest`). Folding
115    /// geometry into `KernelManifest::canonical_signed_bytes` would
116    /// invalidate every previously-signed `twasm-kmf-v2` blob (a magic
117    /// bump to `-v3` plus a dual-version verify path) for no security
118    /// gain, so it is carried as an unsigned hint instead. A bad actor
119    /// who could rewrite this field could at worst mis-hint a launch grid
120    /// for a kernel whose code is already separately authenticated.
121    ///
122    /// `#[serde(default)]` makes manifests serialized before this field
123    /// existed deserialize cleanly (the field reads back as `None`), so
124    /// older on-disk blobs and wire payloads remain forward-compatible.
125    ///
126    /// [`EmittedPtx::launch_geometry`]: crate::ptx_emit::EmittedPtx::launch_geometry
127    #[serde(default)]
128    pub launch_geometry: Option<(u32, u32)>,
129}
130
131impl KernelManifest {
132    /// Construct a manifest from its component fields.
133    ///
134    /// `KernelManifest` is `#[non_exhaustive]`, so foreign crates cannot
135    /// build one via a struct literal — they must go through this
136    /// constructor. The `signature` field is typically left as
137    /// `[0u8; 32]` here and filled in afterwards by [`sign_manifest`],
138    /// because the publisher's HMAC key is what produces the
139    /// signature value.
140    ///
141    /// The optional `launch_geometry` hint defaults to `None`; publishers
142    /// that know the kernel's grid/block geometry chain
143    /// [`Self::with_launch_geometry`] after this constructor to set it.
144    /// Keeping this signature stable (geometry is set via the builder, not
145    /// a new positional argument) is intentional so the cross-crate
146    /// callers in tensor-wasm-cli / tensor-wasm-api and the integration
147    /// tests keep compiling unchanged.
148    pub fn new(
149        name: String,
150        version: String,
151        sm_version: u32,
152        digest: [u8; 32],
153        signature: [u8; 32],
154        published_unix_ms: u64,
155        publisher: String,
156    ) -> Self {
157        Self {
158            name,
159            version,
160            sm_version,
161            digest,
162            signature,
163            published_unix_ms,
164            publisher,
165            launch_geometry: None,
166        }
167    }
168
169    /// Builder: attach an optional launch-geometry hint
170    /// `(grid_size, block_size)` to this manifest and return it.
171    ///
172    /// This is the publisher-facing way to populate the
173    /// [`KernelManifest::launch_geometry`] field without widening
174    /// [`KernelManifest::new`]'s signature. Because geometry is carried
175    /// as an *unsigned* hint (see the field docs), this may be called
176    /// either before or after [`sign_manifest`] without affecting
177    /// signature verification — the canonical signed bytes do not include
178    /// it. Pass `None` to explicitly clear any previously-set hint.
179    pub fn with_launch_geometry(mut self, launch_geometry: Option<(u32, u32)>) -> Self {
180        self.launch_geometry = launch_geometry;
181        self
182    }
183
184    /// First 8 bytes of `digest` interpreted as a little-endian `u64`.
185    /// Used as the synthetic `fingerprint` field on the `CachedKernel`
186    /// that the registry path promotes into L1.
187    pub fn digest_as_u64(&self) -> u64 {
188        let mut buf = [0u8; 8];
189        buf.copy_from_slice(&self.digest[..8]);
190        u64::from_le_bytes(buf)
191    }
192
193    /// Build the canonical v2 signed-bytes blob for this manifest.
194    ///
195    /// Both [`sign_manifest`] and [`InMemoryRegistry::verify_signature`]
196    /// route through this single helper, so the sign and verify
197    /// paths cannot drift. See the module-level docstring for the
198    /// exact wire layout. The output never contains the
199    /// `signature` field itself — the MAC is computed over this
200    /// blob and stored in `signature`.
201    ///
202    /// The optional `launch_geometry` hint is DELIBERATELY excluded from
203    /// this envelope (see [`KernelManifest::launch_geometry`]): it is an
204    /// advisory launch hint, not a security boundary, so adding it here
205    /// (and bumping the `twasm-kmf-v2` magic) would needlessly invalidate
206    /// every previously-signed manifest. Keeping the envelope byte-for-
207    /// byte identical to the prior v2 form means existing signed blobs
208    /// continue to verify unchanged.
209    pub(crate) fn canonical_signed_bytes(&self) -> Vec<u8> {
210        // Pre-size: 12 (magic) + 6*8 (length prefixes) + name + version
211        // + publisher + 8 (ts) + 4 (sm) + 32 (digest). The exact size
212        // doesn't matter for correctness, only for one fewer realloc.
213        let cap = 12
214            + 6 * 8
215            + self.name.len()
216            + self.version.len()
217            + self.publisher.len()
218            + 8
219            + 4
220            + self.digest.len();
221        let mut buf = Vec::with_capacity(cap);
222        buf.extend_from_slice(SIGNED_BYTES_MAGIC_V2);
223        push_len_prefixed(&mut buf, self.name.as_bytes());
224        push_len_prefixed(&mut buf, self.version.as_bytes());
225        push_len_prefixed(&mut buf, self.publisher.as_bytes());
226        push_len_prefixed(&mut buf, &self.published_unix_ms.to_le_bytes());
227        push_len_prefixed(&mut buf, &self.sm_version.to_le_bytes());
228        push_len_prefixed(&mut buf, &self.digest);
229        buf
230    }
231}
232
233/// Magic + version tag prefixed to every v2 canonical signed-bytes
234/// blob. Exactly 12 bytes — keeping it a fixed width means a future
235/// v3 envelope can prepend its own tag without ambiguity. Changing
236/// this constant is a breaking change to every previously-signed
237/// manifest in existence.
238pub(crate) const SIGNED_BYTES_MAGIC_V2: &[u8; 12] = b"twasm-kmf-v2";
239
240/// Append `bytes` to `buf` preceded by a `u64` little-endian length
241/// prefix. Used by [`KernelManifest::canonical_signed_bytes`] to
242/// emit each field in the canonical layout.
243fn push_len_prefixed(buf: &mut Vec<u8>, bytes: &[u8]) {
244    buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
245    buf.extend_from_slice(bytes);
246}
247
248/// Resolves a JIT cache key to a (name, version) tuple that the
249/// registry can look up. The cache is keyed by (tenant, blueprint
250/// fingerprint, sm_version, emit_config_hash); the registry is keyed
251/// by (name, version). This trait is the bridge — embedders provide
252/// the mapping policy (e.g. a YAML manifest baked into the deploy,
253/// or a tenant-level metadata table).
254pub trait BlueprintResolver: Send + Sync {
255    /// Resolve a (blueprint fingerprint, sm_version) pair to a
256    /// `(name, version)` tuple that [`KernelRegistry::get`] understands.
257    /// Returns `None` if the embedder has no mapping for this
258    /// blueprint — the caller then proceeds with fresh PTX emission.
259    fn resolve(&self, blueprint_fp: u64, sm_version: u32) -> Option<(String, String)>;
260}
261
262/// In-memory [`BlueprintResolver`] backed by a `HashMap`. Test-only
263/// convenience for the cache integration tests in v0.3.8; production
264/// embedders supply their own implementation that consults a
265/// deployment-baked manifest or a tenant-level metadata table.
266pub struct InMemoryBlueprintResolver {
267    map: HashMap<(u64, u32), (String, String)>,
268}
269
270impl InMemoryBlueprintResolver {
271    /// Construct an empty resolver. Use [`Self::insert`] to populate
272    /// the (blueprint, sm) → (name, version) mapping.
273    pub fn new() -> Self {
274        Self {
275            map: HashMap::new(),
276        }
277    }
278
279    /// Build a resolver pre-populated from a map of
280    /// `(blueprint_fp, sm_version)` → `(name, version)` entries.
281    pub fn from_map(map: HashMap<(u64, u32), (String, String)>) -> Self {
282        Self { map }
283    }
284
285    /// Insert a single `(blueprint_fp, sm_version)` → `(name, version)`
286    /// mapping. Overwrites any prior entry for the same key.
287    pub fn insert(&mut self, blueprint_fp: u64, sm_version: u32, name: String, version: String) {
288        self.map.insert((blueprint_fp, sm_version), (name, version));
289    }
290}
291
292impl Default for InMemoryBlueprintResolver {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298impl BlueprintResolver for InMemoryBlueprintResolver {
299    fn resolve(&self, blueprint_fp: u64, sm_version: u32) -> Option<(String, String)> {
300        self.map.get(&(blueprint_fp, sm_version)).cloned()
301    }
302}
303
304/// Failure modes for registry operations.
305#[derive(Debug, thiserror::Error)]
306pub enum RegistryError {
307    /// No entry for the requested `name@version`.
308    #[error("kernel not found: {0}")]
309    NotFound(String),
310    /// HMAC verification failed against the configured signing key.
311    #[error("signature verification failed for {0}")]
312    BadSignature(String),
313    /// BLAKE3 of the PTX text does not match `manifest.digest`.
314    #[error("digest mismatch for {0}")]
315    DigestMismatch(String),
316    /// A manifest with the same `name@version` already exists.
317    #[error("name @ version already registered: {0}")]
318    AlreadyRegistered(String),
319    /// The manifest's `publisher` field is not in the registry's
320    /// configured allowlist. Only returned by the [`DiskRegistry`] when
321    /// it has been built with `Some(publisher_allowlist)`; the
322    /// allowlist-less default and [`InMemoryRegistry`] never emit this.
323    /// The string carries the manifest name for parity with the other
324    /// rejection variants — the publisher itself is deliberately NOT
325    /// echoed back to avoid leaking the allowlist contents through
326    /// negative-result probing.
327    #[error("publisher not allowlisted for {0}")]
328    PublisherNotAllowed(String),
329    /// The underlying on-disk artifact store rejected a put/get/list. The
330    /// inner `String` is the `ArtifactError`'s `Display`; we collapse to
331    /// a single variant because the registry layer treats every storage
332    /// failure as a transient backend error rather than re-deriving one
333    /// HTTP status per inner cause. Only emitted by [`DiskRegistry`].
334    #[error("artifact store error: {0}")]
335    Storage(String),
336    /// bincode encode/decode of the on-disk manifest blob failed. The
337    /// disk format pairs a [`KernelManifest`] with its PTX text under a
338    /// single bincode envelope; a decode failure here usually means the
339    /// blob was written by an incompatible bincode version, not that
340    /// the bytes were tampered with (the artifact store's HMAC catches
341    /// the latter). Only emitted by [`DiskRegistry`].
342    #[error("manifest codec error: {0}")]
343    Codec(String),
344}
345
346/// Registry trait. v0.3.7 ships an in-memory impl; v0.4 lands disk +
347/// remote backends. The pair returned by [`Self::get`] is
348/// `(manifest, ptx_text)` so a caller can hand the PTX to the JIT cache
349/// without a second registry round-trip.
350pub trait KernelRegistry: Send + Sync {
351    /// Resolve `(name, version)` to the verified manifest + PTX text.
352    fn get(
353        &self,
354        name: &str,
355        version: &str,
356    ) -> Result<Arc<(KernelManifest, String)>, RegistryError>;
357    /// Verify and persist a signed manifest + PTX text.
358    fn publish(&self, manifest: KernelManifest, ptx_text: String) -> Result<(), RegistryError>;
359    /// Enumerate all registered manifests (PTX text omitted).
360    fn list(&self) -> Vec<KernelManifest>;
361    /// Page-aware listing. Default implementation slices the result of
362    /// [`Self::list`] — backends with native pagination support (see
363    /// [`DiskRegistry::list_paginated`]) override this to avoid
364    /// materialising the full set when only a page is asked for.
365    ///
366    /// `limit` is clamped to [`DISK_REGISTRY_MAX_LIMIT`] (1000) by the
367    /// default impl, mirroring what the disk-backed override does, so
368    /// the HTTP route's contract is the same regardless of backend.
369    fn list_paginated(&self, offset: usize, limit: usize) -> Vec<KernelManifest> {
370        let limit = limit.min(DISK_REGISTRY_MAX_LIMIT);
371        self.list().into_iter().skip(offset).take(limit).collect()
372    }
373}
374
375/// In-memory implementation for v0.3.7. Holds manifests + PTX text in a
376/// `Mutex<HashMap>` keyed by `"{name}@{version}"`.
377///
378/// The HMAC-SHA256 signing key is held in a [`zeroize::Zeroizing`] so the
379/// scrub-on-drop guarantees from the snapshot signing path apply here too:
380/// the secret material does not linger in the heap arena after the
381/// registry is dropped.
382pub struct InMemoryRegistry {
383    entries: parking_lot::Mutex<HashMap<String, Arc<(KernelManifest, String)>>>,
384    hmac_key: zeroize::Zeroizing<[u8; 32]>,
385}
386
387impl InMemoryRegistry {
388    /// Construct an empty registry that accepts manifests signed under
389    /// `hmac_key`.
390    pub fn new(hmac_key: [u8; 32]) -> Self {
391        Self {
392            entries: parking_lot::Mutex::new(HashMap::new()),
393            hmac_key: zeroize::Zeroizing::new(hmac_key),
394        }
395    }
396
397    /// Verify, then insert, a signed manifest plus its PTX text.
398    ///
399    /// Returns [`RegistryError::DigestMismatch`] if BLAKE3 of `ptx_text`
400    /// does not match `manifest.digest`, [`RegistryError::BadSignature`]
401    /// if the HMAC does not verify under the configured key, and
402    /// [`RegistryError::AlreadyRegistered`] if `name@version` is already
403    /// present. The check order (digest → signature → uniqueness) is
404    /// deliberate: a digest mismatch is the cheapest tell that the
405    /// PTX/manifest pair was corrupted in transit, so we surface that
406    /// before doing the constant-time HMAC compare.
407    pub fn publish(&self, manifest: KernelManifest, ptx_text: String) -> Result<(), RegistryError> {
408        // Verify digest matches PTX.
409        let actual = blake3::hash(ptx_text.as_bytes());
410        if actual.as_bytes() != &manifest.digest {
411            return Err(RegistryError::DigestMismatch(manifest.name.clone()));
412        }
413        // Verify signature.
414        self.verify_signature(&manifest)?;
415        let key = format!("{}@{}", manifest.name, manifest.version);
416        let mut entries = self.entries.lock();
417        if entries.contains_key(&key) {
418            return Err(RegistryError::AlreadyRegistered(key));
419        }
420        entries.insert(key, Arc::new((manifest, ptx_text)));
421        Ok(())
422    }
423
424    /// Recompute and constant-time-compare the manifest's HMAC against
425    /// the configured signing key. See the module-level docstring for
426    /// the exact envelope layout.
427    fn verify_signature(&self, manifest: &KernelManifest) -> Result<(), RegistryError> {
428        verify_manifest_signature(manifest, &self.hmac_key)
429    }
430}
431
432/// Free-function HMAC verification against `hmac_key`.
433///
434/// Shared by [`InMemoryRegistry::verify_signature`] and
435/// [`DiskRegistry::verify_signature`] so the sign/verify byte-stream
436/// derived from [`KernelManifest::canonical_signed_bytes`] cannot drift
437/// across backends. The comparison is constant-time via
438/// `subtle::ConstantTimeEq` so a tight publish loop cannot recover bits
439/// of the expected MAC through repeated rejections.
440fn verify_manifest_signature(
441    manifest: &KernelManifest,
442    hmac_key: &[u8; 32],
443) -> Result<(), RegistryError> {
444    use hmac::{Hmac, Mac};
445    // `new_from_slice` only errors on invalid key length; ours is a
446    // compile-time 32-byte array so the unwrap is sound.
447    let mut mac = <Hmac<sha2::Sha256> as Mac>::new_from_slice(&hmac_key[..])
448        .expect("32-byte key is always valid HMAC-SHA256 input");
449    mac.update(&manifest.canonical_signed_bytes());
450    let expected = mac.finalize().into_bytes();
451    let ok = subtle::ConstantTimeEq::ct_eq(&expected[..], &manifest.signature[..]);
452    if bool::from(ok) {
453        Ok(())
454    } else {
455        Err(RegistryError::BadSignature(manifest.name.clone()))
456    }
457}
458
459impl KernelRegistry for InMemoryRegistry {
460    fn get(
461        &self,
462        name: &str,
463        version: &str,
464    ) -> Result<Arc<(KernelManifest, String)>, RegistryError> {
465        let key = format!("{name}@{version}");
466        self.entries
467            .lock()
468            .get(&key)
469            .cloned()
470            .ok_or(RegistryError::NotFound(key))
471    }
472
473    fn publish(&self, manifest: KernelManifest, ptx_text: String) -> Result<(), RegistryError> {
474        InMemoryRegistry::publish(self, manifest, ptx_text)
475    }
476
477    fn list(&self) -> Vec<KernelManifest> {
478        self.entries.lock().values().map(|e| e.0.clone()).collect()
479    }
480}
481
482/// Sign a manifest given a publisher's HMAC key. Helper for tests and
483/// the v0.4 signing CLI.
484///
485/// Callers populate every field of `unsigned` (including
486/// `signature: [0; 32]`); this helper computes the real signature over
487/// the canonical envelope and returns it. The caller is then responsible
488/// for writing it back into `unsigned.signature` before publishing.
489pub fn sign_manifest(unsigned: &KernelManifest, hmac_key: &[u8; 32]) -> [u8; 32] {
490    use hmac::{Hmac, Mac};
491    let mut mac = <Hmac<sha2::Sha256> as Mac>::new_from_slice(hmac_key)
492        .expect("32-byte key is always valid HMAC-SHA256 input");
493    mac.update(&unsigned.canonical_signed_bytes());
494    mac.finalize().into_bytes().into()
495}
496
497// =====================================================================
498// DiskRegistry — production disk-persisted kernel registry (T35, v0.4)
499// =====================================================================
500
501/// Default value returned by [`DiskRegistry::list`] and the cap on
502/// `limit` accepted by [`DiskRegistry::list_paginated`]. 100 / 1000
503/// were chosen to match the conservative pagination defaults the
504/// snapshot-listing path uses; operators with larger fleets can pass an
505/// explicit `limit` up to [`DISK_REGISTRY_MAX_LIMIT`].
506pub const DISK_REGISTRY_DEFAULT_LIMIT: usize = 100;
507
508/// Hard ceiling on `limit` accepted by
509/// [`DiskRegistry::list_paginated`]. Requests with `limit` above the
510/// ceiling are silently clamped; the response is therefore always
511/// bounded regardless of caller input.
512pub const DISK_REGISTRY_MAX_LIMIT: usize = 1000;
513
514/// Defaults pagination parameters: `(0, DISK_REGISTRY_DEFAULT_LIMIT)`.
515/// `list` forwards through this so a future change to either constant
516/// only needs to touch one place.
517const DISK_REGISTRY_DEFAULT_LIST: (usize, usize) = (0, DISK_REGISTRY_DEFAULT_LIMIT);
518
519/// Bincode envelope: `(KernelManifest, ptx_text)`. The on-disk artifact
520/// store keys by BLAKE3 of these bytes, so two equal manifest+PTX
521/// pairs deduplicate naturally. The PTX rides along inside the same
522/// blob — separating PTX from manifest would force two `get` round
523/// trips per `resolve` and bring no integrity benefit (both fields are
524/// covered by the artifact store's outer HMAC envelope).
525///
526/// We use a tuple rather than a dedicated struct because the trait
527/// surface (`KernelRegistry::get` returns `Arc<(KernelManifest, String)>`)
528/// already exposes this exact shape; introducing a struct would push a
529/// new public type onto the API surface for no reader-side benefit.
530type ManifestBlob = (KernelManifest, String);
531
532/// Keymap value: the artifact-store [`ContentHash`] for a published
533/// kernel, paired with the decoded-and-already-verified
534/// [`KernelManifest`] (PTX text deliberately omitted).
535///
536/// Caching the manifest here serves two findings:
537///
538/// * **PERF (`list_paginated`)** — listings can be served straight from
539///   this cached manifest without a per-entry artifact-store `get`
540///   (HMAC + zstd decode of the full PTX-bearing blob) that is then
541///   thrown away. Listing is O(entries) keymap reads instead of
542///   O(entries) decompressions.
543///
544/// * **LOW perf (`get` re-verify)** — both [`DiskRegistry::open`] and
545///   [`DiskRegistry::publish`] verify the manifest signature *before*
546///   the entry is admitted into the keymap, so the cached manifest is
547///   by construction one that has already passed the registry's v2 HMAC
548///   check under the current key. `get` can skip the redundant per-
549///   resolve re-verification of the manifest signature: the bytes it
550///   then pulls from the artifact store are still independently
551///   authenticated by the store's own HMAC envelope, and the manifest
552///   embedded in this cache is exactly the one that was verified at
553///   admission time. No security is weakened — the store HMAC still
554///   guards on-disk integrity, and key-rotation skipping still happens
555///   at `open` time.
556#[derive(Clone)]
557struct KeymapEntry {
558    hash: ContentHash,
559    /// Decoded manifest, PTX stripped. Verified under the registry
560    /// HMAC key at admission time (publish or restart-recovery).
561    manifest: KernelManifest,
562}
563
564/// Production disk-persisted kernel registry (roadmap feature #3, T35).
565///
566/// `DiskRegistry` is a thin layer on top of
567/// [`tensor_wasm_artifacts::DiskArtifactStore`] — the artifact store
568/// brings HMAC-SHA256 envelope, zstd compression, content-addressing,
569/// size caps, and atomic rename-on-publish. The registry contributes
570/// the application-level keymap
571/// `(name, version, sm_version) → (ContentHash + cached manifest)`
572/// so callers can resolve manifests by stable `name@version` (with
573/// `sm_version` as a tie-breaker for kernels published for multiple
574/// compute capabilities).
575///
576/// ## Wire-format guarantee
577///
578/// Manifests are still subject to the same v2-envelope HMAC-SHA256
579/// signature [`InMemoryRegistry`] enforces (the registry's own,
580/// independent signing key). The artifact store's HMAC is a SEPARATE
581/// integrity layer over the bincode-encoded `(manifest, ptx_text)`
582/// blob; both must verify before a manifest is exposed to the caller.
583/// This is defence in depth: the artifact store's HMAC catches bit
584/// flips on disk, and the manifest's own signature proves the
585/// publisher signed off on the *content* of the manifest (and
586/// transitively the PTX via `digest`).
587///
588/// ## Restart recovery
589///
590/// `DiskRegistry::open` reads every blob in the artifact store's
591/// directory, decodes each one, and re-populates the in-memory keymap.
592/// A blob that fails the artifact store's HMAC, fails bincode decode,
593/// or fails the registry's own signature check is skipped with a
594/// `tracing::warn` — restart is best-effort and never panics on partial
595/// corruption.
596///
597/// ## Threat model — publisher allowlist
598///
599/// Today every caller in possession of the registry HMAC key can sign a
600/// manifest under any `publisher` field they please. The v2 envelope
601/// (T12) covers `publisher` and `published_unix_ms` cryptographically,
602/// so a passive intermediary cannot rewrite `publisher` post-sign — but
603/// a legitimate holder of the signing key still has free rein.
604///
605/// The optional `publisher_allowlist` field closes that gap by
606/// refusing `publish` when `manifest.publisher` is not in the
607/// configured set, regardless of whether the signature otherwise
608/// verifies. This is a separate authorization layer over and above the
609/// HTTP route's kernel-publish scope check (T1) — the route check
610/// gates *who can call publish at all*, while the allowlist gates
611/// *which publisher field the call can claim*. With both layers in
612/// force, an operator can have a single signing key shared across
613/// trusted publishers without any one publisher being able to forge
614/// claims under another publisher's identity.
615///
616/// `None` (the default) preserves the historical permissive behaviour
617/// — any signed publisher is accepted — and matches what
618/// [`InMemoryRegistry`] does today.
619pub struct DiskRegistry {
620    /// Underlying content-addressed signed blob store. Holds the
621    /// bincode-encoded `(KernelManifest, ptx_text)` payloads.
622    artifact_store: DiskArtifactStore,
623    /// `(name, version, sm_version) → KeymapEntry` keymap. The DashMap
624    /// behind an `Arc` lets concurrent `publish` / `resolve` callers
625    /// proceed in parallel without coarse locking; the artifact store
626    /// itself is also `Send + Sync` so neither layer becomes the
627    /// serialisation point.
628    ///
629    /// The value caches the decoded, signature-verified manifest
630    /// alongside its `ContentHash` so listings need not re-fetch and
631    /// re-decompress each blob — see [`KeymapEntry`].
632    keymap: Arc<DashMap<(String, String, u32), KeymapEntry>>,
633    /// Secondary index: `(name, version) → sorted Vec<sm_version>`.
634    ///
635    /// jit PERF fix (finding 11): [`KernelRegistry::get`] resolves
636    /// `(name, version)` to the highest available `sm_version`. The previous
637    /// implementation scanned the ENTIRE keymap with
638    /// `iter().filter(...).max_by_key(...)` — O(N) in the total number of
639    /// registered `(name, version, sm)` triples on every resolve. This
640    /// index lets `get` look up the candidate sm-versions for a single
641    /// `(name, version)` directly and read the max off the (kept-sorted)
642    /// tail, turning the hot lookup into an O(1) map probe plus an O(1)
643    /// `last()`. Kept in lock-step with `keymap` on every insert.
644    sm_index: Arc<DashMap<(String, String), Vec<u32>>>,
645    /// Manifest signature key. Held in [`zeroize::Zeroizing`] so the
646    /// scrub-on-Drop guarantees from the snapshot signing path apply
647    /// here too — the secret does not linger in the heap arena after
648    /// the registry is dropped.
649    hmac_key: zeroize::Zeroizing<[u8; 32]>,
650    /// Optional set of publisher identities the registry will accept on
651    /// `publish`. See the type-level docstring for the threat model.
652    /// `None` = permissive (any signed publisher is accepted), `Some`
653    /// = strict allowlist.
654    publisher_allowlist: Option<HashSet<String>>,
655}
656
657/// Insert `sm` into the `(name, version) → sorted [sm]` secondary index,
658/// keeping the per-key list sorted ascending and de-duplicated.
659///
660/// jit PERF fix (finding 11): the list is kept sorted so
661/// [`KernelRegistry::get`] can read the highest sm-version off the tail in
662/// O(1). Insertion is O(log k + k) in the (tiny) number of sm-versions per
663/// `(name, version)` — negligible next to the artifact-store write it
664/// accompanies, and a one-time cost paid only on publish / restart-recovery,
665/// not on the hot resolve path.
666fn index_sm_version(
667    index: &DashMap<(String, String), Vec<u32>>,
668    name: String,
669    version: String,
670    sm: u32,
671) {
672    let mut entry = index.entry((name, version)).or_default();
673    match entry.binary_search(&sm) {
674        // Already present (publish rejects duplicate triples upstream, but
675        // restart-recovery can re-see one if a blob is double-listed).
676        Ok(_) => {}
677        Err(pos) => entry.insert(pos, sm),
678    }
679}
680
681impl DiskRegistry {
682    /// Construct (or re-open) a disk-persisted kernel registry rooted at
683    /// `dir`, signing/verifying manifests under `hmac_key`.
684    ///
685    /// On success, the artifact store directory is opened (created
686    /// lazily on first put) and the keymap is rebuilt from any blobs
687    /// already on disk. Each blob is read via
688    /// [`DiskArtifactStore::get`] (which runs the artifact store's
689    /// HMAC + zstd verification), bincode-decoded into a
690    /// `(KernelManifest, ptx_text)` pair, signature-verified under
691    /// `hmac_key`, and only then admitted into the keymap. Corrupt or
692    /// foreign-keyed blobs are skipped with a `tracing::warn`; the
693    /// registry never panics on a partial-corruption restart.
694    ///
695    /// The returned registry has no publisher allowlist (permissive
696    /// mode); chain through [`Self::with_publisher_allowlist`] to
697    /// enable one.
698    pub fn open(dir: PathBuf, hmac_key: [u8; 32]) -> Result<Self, RegistryError> {
699        let artifact_store = DiskArtifactStore::new(dir, hmac_key);
700        let keymap: Arc<DashMap<(String, String, u32), KeymapEntry>> = Arc::new(DashMap::new());
701        let sm_index: Arc<DashMap<(String, String), Vec<u32>>> = Arc::new(DashMap::new());
702
703        // Restart-recovery: walk every blob in the store, decode it,
704        // re-verify the manifest signature under the same hmac_key,
705        // and re-populate the keymap. Best-effort: a blob that fails
706        // any check is logged and skipped — we'd rather come up with a
707        // partially-populated keymap than refuse to boot.
708        let hashes = artifact_store.list().map_err(|e| {
709            tracing::warn!(
710                target: "tensor_wasm_jit::registry",
711                error = %e,
712                "restart-recovery: artifact store list failed; refusing to boot with an \
713                 unknown-completeness keymap"
714            );
715            RegistryError::Storage(e.to_string())
716        })?;
717        for hash in hashes {
718            let raw = match artifact_store.get(&hash) {
719                Ok(bytes) => bytes,
720                Err(e) => {
721                    tracing::warn!(
722                        target: "tensor_wasm_jit::registry",
723                        hash = %hash,
724                        error = %e,
725                        "restart-recovery: artifact store read failed; skipping blob"
726                    );
727                    continue;
728                }
729            };
730            let (manifest, _ptx) = match decode_manifest_blob(&raw) {
731                Ok(pair) => pair,
732                Err(e) => {
733                    tracing::warn!(
734                        target: "tensor_wasm_jit::registry",
735                        hash = %hash,
736                        error = %e,
737                        "restart-recovery: bincode decode failed; skipping blob"
738                    );
739                    continue;
740                }
741            };
742            // Defence in depth: the artifact store already authenticated
743            // the bytes, but a freshly-rotated registry HMAC key would
744            // mean the manifest's own signature no longer verifies
745            // even though the store envelope still does. Skip those
746            // rather than serve them.
747            if let Err(e) = verify_manifest_signature(&manifest, &hmac_key) {
748                tracing::warn!(
749                    target: "tensor_wasm_jit::registry",
750                    name = manifest.name.as_str(),
751                    version = manifest.version.as_str(),
752                    error = %e,
753                    "restart-recovery: manifest signature does not verify under \
754                     current hmac_key; skipping blob (registry key may have rotated)"
755                );
756                continue;
757            }
758            let k = (
759                manifest.name.clone(),
760                manifest.version.clone(),
761                manifest.sm_version,
762            );
763            // Cache the verified manifest alongside the hash so listings
764            // and resolves avoid a redundant decode/verify (see
765            // `KeymapEntry`). `manifest` has just passed the v2 HMAC
766            // check above.
767            let (name, version, sm) = (k.0.clone(), k.1.clone(), k.2);
768            keymap.insert(k, KeymapEntry { hash, manifest });
769            index_sm_version(&sm_index, name, version, sm);
770        }
771
772        Ok(Self {
773            artifact_store,
774            keymap,
775            sm_index,
776            hmac_key: zeroize::Zeroizing::new(hmac_key),
777            publisher_allowlist: None,
778        })
779    }
780
781    /// Builder method: install a publisher allowlist. See the
782    /// type-level docstring for the threat model. Chain after
783    /// [`Self::open`]:
784    ///
785    /// ```ignore
786    /// let reg = DiskRegistry::open(dir, key)?
787    ///     .with_publisher_allowlist(["alice", "bob"].iter().map(|s| s.to_string()).collect());
788    /// ```
789    pub fn with_publisher_allowlist(mut self, allowlist: HashSet<String>) -> Self {
790        self.publisher_allowlist = Some(allowlist);
791        self
792    }
793
794    /// Recompute and constant-time-compare the manifest's HMAC against
795    /// the registry's configured key. Mirrors
796    /// [`InMemoryRegistry::verify_signature`] — both route through the
797    /// same [`verify_manifest_signature`] helper.
798    fn verify_signature(&self, manifest: &KernelManifest) -> Result<(), RegistryError> {
799        verify_manifest_signature(manifest, &self.hmac_key)
800    }
801
802    /// Verify + persist a signed manifest plus its PTX text.
803    ///
804    /// Check order:
805    /// 1. `publisher` allowlist (if configured) — cheapest failure for
806    ///    a publisher who legitimately holds the signing key but is not
807    ///    authorised to publish under their claimed identity.
808    /// 2. BLAKE3 digest match against `ptx_text`.
809    /// 3. HMAC v2 signature verification.
810    /// 4. Uniqueness — refuse if `(name, version, sm_version)` is
811    ///    already in the keymap.
812    /// 5. bincode-encode the `(manifest, ptx_text)` pair and hand it to
813    ///    the artifact store. The store computes the BLAKE3
814    ///    content-address and HMAC-signs the envelope; the returned
815    ///    [`ContentHash`] is recorded in the keymap.
816    pub fn publish(&self, manifest: KernelManifest, ptx_text: String) -> Result<(), RegistryError> {
817        // Step 1: publisher allowlist (operator-side authorization).
818        if let Some(allow) = &self.publisher_allowlist {
819            if !allow.contains(&manifest.publisher) {
820                return Err(RegistryError::PublisherNotAllowed(manifest.name.clone()));
821            }
822        }
823        // Step 2: digest match.
824        let actual = blake3::hash(ptx_text.as_bytes());
825        if actual.as_bytes() != &manifest.digest {
826            return Err(RegistryError::DigestMismatch(manifest.name.clone()));
827        }
828        // Step 3: HMAC signature.
829        self.verify_signature(&manifest)?;
830        // Step 4: uniqueness on (name, version, sm_version). We key on
831        // the (name, version, sm_version) triple rather than the
832        // {name@version} string so two PTX flavours of the same kernel
833        // for different SM versions can coexist; the InMemoryRegistry
834        // collapses these into one slot, but the disk-backed path is
835        // expected to host the production matrix.
836        let key = (
837            manifest.name.clone(),
838            manifest.version.clone(),
839            manifest.sm_version,
840        );
841        if self.keymap.contains_key(&key) {
842            return Err(RegistryError::AlreadyRegistered(format!(
843                "{}@{} (sm_{})",
844                manifest.name, manifest.version, manifest.sm_version,
845            )));
846        }
847
848        // Step 5: bincode encode and persist via artifact store.
849        // Keep a manifest clone for the keymap cache before the original
850        // is moved into the blob — `manifest` has already passed the
851        // digest + v2 HMAC checks above, so the cached copy is verified
852        // by construction (see `KeymapEntry`).
853        let cached_manifest = manifest.clone();
854        let blob: ManifestBlob = (manifest, ptx_text);
855        let bytes = encode_manifest_blob(&blob)?;
856        let hash = self
857            .artifact_store
858            .put(&bytes)
859            .map_err(|e| RegistryError::Storage(e.to_string()))?;
860        let (name, version, sm) = (key.0.clone(), key.1.clone(), key.2);
861        self.keymap.insert(
862            key,
863            KeymapEntry {
864                hash,
865                manifest: cached_manifest,
866            },
867        );
868        // jit PERF fix (finding 11): keep the secondary `(name, version) →
869        // [sm]` index in lock-step with the keymap so `get` resolves the
870        // highest sm in O(1) instead of an O(N) keymap scan.
871        index_sm_version(&self.sm_index, name, version, sm);
872        Ok(())
873    }
874
875    /// Page-aware listing: skip the first `offset` keymap entries and
876    /// return up to `limit` manifests. `limit` is clamped to
877    /// [`DISK_REGISTRY_MAX_LIMIT`].
878    ///
879    /// The iteration order is NOT specified — DashMap's iteration is
880    /// hash-bucket order, which is stable per process but differs
881    /// across restarts. Callers that need a deterministic page sequence
882    /// should sort the result themselves on whatever field they care
883    /// about (`name`, `published_unix_ms`, etc.).
884    pub fn list_paginated(&self, offset: usize, limit: usize) -> Vec<KernelManifest> {
885        let limit = limit.min(DISK_REGISTRY_MAX_LIMIT);
886        let mut out: Vec<KernelManifest> = Vec::with_capacity(limit);
887        // FINDING (PERF): serve listings from the cached manifest in the
888        // keymap value rather than doing a per-entry artifact-store `get`
889        // (HMAC + zstd decode of the full PTX-bearing blob) only to throw
890        // the PTX away. The cached manifest was signature-verified at
891        // admission time (publish / restart-recovery), and listings omit
892        // PTX by convention (same as `InMemoryRegistry::list`), so the
893        // blob fetch + decode was pure waste — O(N) decompressions per
894        // listing. We now just clone the cached manifest.
895        for (i, kv) in self.keymap.iter().enumerate() {
896            if i < offset {
897                continue;
898            }
899            if out.len() >= limit {
900                break;
901            }
902            out.push(kv.value().manifest.clone());
903        }
904        out
905    }
906}
907
908/// Encode a `(KernelManifest, ptx_text)` pair for the artifact store.
909///
910/// Routes through `bincode::serde::encode_to_vec` with the
911/// `bincode::config::legacy()` configuration so the on-disk format
912/// matches the encoding the snapshot crate uses for its v3 payload —
913/// keeping both stores on the same bincode flavour means we can adopt
914/// a future codec migration in one place.
915fn encode_manifest_blob(blob: &ManifestBlob) -> Result<Vec<u8>, RegistryError> {
916    bincode::serde::encode_to_vec(blob, bincode::config::legacy())
917        .map_err(|e| RegistryError::Codec(e.to_string()))
918}
919
920/// Inverse of [`encode_manifest_blob`].
921fn decode_manifest_blob(bytes: &[u8]) -> Result<ManifestBlob, RegistryError> {
922    let (blob, _consumed): (ManifestBlob, usize) =
923        bincode::serde::decode_from_slice(bytes, bincode::config::legacy())
924            .map_err(|e| RegistryError::Codec(e.to_string()))?;
925    Ok(blob)
926}
927
928impl KernelRegistry for DiskRegistry {
929    fn get(
930        &self,
931        name: &str,
932        version: &str,
933    ) -> Result<Arc<(KernelManifest, String)>, RegistryError> {
934        // Find the highest sm_version present for (name, version). The
935        // trait surface keys on (name, version) only — when multiple
936        // sm_versions exist for the same name/version pair we pick the
937        // entry with the maximum sm_version (the 3rd key element).
938        // Embedders that need sm-specific resolution can bypass
939        // `KernelRegistry::get` and call `list_paginated` to discover
940        // the matrix.
941        //
942        // FINDING (MEDIUM correctness): we resolve to the entry with the
943        // maximum `sm_version` (deterministic across restarts, unlike the
944        // old DashMap-iteration-order `.find(...)`), matching the doc
945        // contract ("the highest sm_version").
946        //
947        // jit PERF fix (finding 11): use the `(name, version) → [sm]`
948        // secondary index instead of an O(N) keymap scan. The index keeps
949        // the sm-version list sorted ascending, so the highest sm is the
950        // last element — an O(1) read after an O(1) map probe. We then look
951        // up the exact `(name, version, sm)` triple in the keymap and copy
952        // out its `ContentHash` (a `Copy` value) rather than holding a
953        // DashMap iterator across the artifact-store get (long-held entries
954        // block writers).
955        let best_sm: Option<u32> = self
956            .sm_index
957            .get(&(name.to_string(), version.to_string()))
958            .and_then(|entry| entry.value().last().copied());
959        let hit: Option<ContentHash> = best_sm.and_then(|sm| {
960            self.keymap
961                .get(&(name.to_string(), version.to_string(), sm))
962                .map(|kv| kv.value().hash)
963        });
964        let hash = match hit {
965            Some(h) => h,
966            None => {
967                return Err(RegistryError::NotFound(format!("{name}@{version}")));
968            }
969        };
970        let raw = self.artifact_store.get(&hash).map_err(|e| match e {
971            ArtifactError::NotFound(_) => {
972                // Keymap and disk disagree — could happen if the
973                // operator manually deleted a blob. Treat as a
974                // miss; the keymap entry is now a tombstone but
975                // we don't proactively clean it up here.
976                RegistryError::NotFound(format!("{name}@{version}"))
977            }
978            other => RegistryError::Storage(other.to_string()),
979        })?;
980        let (manifest, ptx_text) = decode_manifest_blob(&raw)?;
981        // FINDING (LOW perf): we no longer re-run the manifest v2 HMAC
982        // verification on every resolve. The keymap only ever contains
983        // entries whose manifest passed `verify_signature` at admission
984        // time (publish) or at restart-recovery (`open`, which also drops
985        // any blob that fails to verify under the *current* key, so a
986        // rotated key is handled there). The bytes we just read are
987        // independently authenticated by the artifact store's own HMAC
988        // envelope keyed on the same content hash, so on-disk integrity
989        // is still covered — this removes a redundant per-resolve MAC,
990        // not a security layer. `self.verify_signature` is retained for
991        // the publish/open paths; deliberately not invoked here.
992        Ok(Arc::new((manifest, ptx_text)))
993    }
994
995    fn publish(&self, manifest: KernelManifest, ptx_text: String) -> Result<(), RegistryError> {
996        DiskRegistry::publish(self, manifest, ptx_text)
997    }
998
999    fn list(&self) -> Vec<KernelManifest> {
1000        let (offset, limit) = DISK_REGISTRY_DEFAULT_LIST;
1001        DiskRegistry::list_paginated(self, offset, limit)
1002    }
1003
1004    /// Override the trait default with the native paginated walk over
1005    /// the keymap (no intermediate full `list()` materialisation).
1006    fn list_paginated(&self, offset: usize, limit: usize) -> Vec<KernelManifest> {
1007        DiskRegistry::list_paginated(self, offset, limit)
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014
1015    /// Helper: build a manifest for `ptx_text` signed under `key`.
1016    /// In-crate tests are allowed to use struct-literal construction
1017    /// (the `#[non_exhaustive]` attribute only restricts foreign crates),
1018    /// but the helper here mirrors the public `KernelManifest::new`
1019    /// flow so the unit and integration tests stay in lock-step.
1020    fn signed_manifest(
1021        name: &str,
1022        version: &str,
1023        ptx_text: &str,
1024        key: &[u8; 32],
1025    ) -> KernelManifest {
1026        let digest = *blake3::hash(ptx_text.as_bytes()).as_bytes();
1027        let mut m = KernelManifest::new(
1028            name.to_string(),
1029            version.to_string(),
1030            80,
1031            digest,
1032            [0u8; 32],
1033            0,
1034            "test".to_string(),
1035        );
1036        m.signature = sign_manifest(&m, key);
1037        m
1038    }
1039
1040    /// Like [`signed_manifest`] but lets the caller pick `sm_version`,
1041    /// for the DiskRegistry highest-sm resolution test below.
1042    fn signed_manifest_sm(
1043        name: &str,
1044        version: &str,
1045        sm_version: u32,
1046        ptx_text: &str,
1047        key: &[u8; 32],
1048    ) -> KernelManifest {
1049        let digest = *blake3::hash(ptx_text.as_bytes()).as_bytes();
1050        let mut m = KernelManifest::new(
1051            name.to_string(),
1052            version.to_string(),
1053            sm_version,
1054            digest,
1055            [0u8; 32],
1056            0,
1057            "test".to_string(),
1058        );
1059        m.signature = sign_manifest(&m, key);
1060        m
1061    }
1062
1063    /// FINDING (MEDIUM correctness) regression: when several
1064    /// `sm_version` builds exist for one `(name, version)`,
1065    /// `DiskRegistry::get` MUST resolve the HIGHEST sm_version
1066    /// deterministically (per the doc contract), not whichever the
1067    /// DashMap happens to iterate first. We publish sm_70, sm_90, sm_80
1068    /// (intentionally out of order) under distinct PTX so the resolved
1069    /// manifest is unambiguous, then assert `get` returns sm_90.
1070    #[test]
1071    fn disk_get_resolves_highest_sm_version() {
1072        let key = [0x42u8; 32];
1073        let tmp = tempfile::TempDir::new().expect("tempdir");
1074        let reg = DiskRegistry::open(tmp.path().to_path_buf(), key).expect("open");
1075
1076        // Distinct PTX per sm so the digests (and thus blobs) differ and
1077        // we can tell which entry was resolved.
1078        for sm in [70u32, 90, 80] {
1079            let ptx = format!("// ptx for sm_{sm}\n");
1080            let m = signed_manifest_sm("matmul.f32", "1.0.0", sm, &ptx, &key);
1081            KernelRegistry::publish(&reg, m, ptx).expect("publish");
1082        }
1083
1084        let got = KernelRegistry::get(&reg, "matmul.f32", "1.0.0").expect("get");
1085        assert_eq!(
1086            got.0.sm_version, 90,
1087            "get must resolve the highest sm_version"
1088        );
1089        assert_eq!(got.1, "// ptx for sm_90\n");
1090    }
1091
1092    /// jit PERF fix (finding 11): the `(name, version) → [sm]` secondary
1093    /// index must be rebuilt on restart-recovery (`open`) so a reopened
1094    /// registry still resolves the highest sm_version via the index, not
1095    /// just a freshly-published one.
1096    #[test]
1097    fn disk_sm_index_survives_restart() {
1098        let key = [0x37u8; 32];
1099        let tmp = tempfile::TempDir::new().expect("tempdir");
1100        {
1101            let reg = DiskRegistry::open(tmp.path().to_path_buf(), key).expect("open");
1102            for sm in [75u32, 86, 80] {
1103                let ptx = format!("// ptx for sm_{sm}\n");
1104                let m = signed_manifest_sm("conv.f32", "2.1.0", sm, &ptx, &key);
1105                KernelRegistry::publish(&reg, m, ptx).expect("publish");
1106            }
1107        }
1108        // Reopen: the index is rebuilt from the on-disk blobs in `open`.
1109        let reopened = DiskRegistry::open(tmp.path().to_path_buf(), key).expect("reopen");
1110        let got = KernelRegistry::get(&reopened, "conv.f32", "2.1.0").expect("get after restart");
1111        assert_eq!(
1112            got.0.sm_version, 86,
1113            "reopened registry must still resolve the highest sm_version via the rebuilt index"
1114        );
1115        assert_eq!(got.1, "// ptx for sm_86\n");
1116        // A name/version that was never published still misses.
1117        assert!(matches!(
1118            KernelRegistry::get(&reopened, "conv.f32", "9.9.9"),
1119            Err(RegistryError::NotFound(_))
1120        ));
1121    }
1122
1123    #[test]
1124    fn publish_and_get_roundtrip() {
1125        let key = [0x42u8; 32];
1126        let reg = InMemoryRegistry::new(key);
1127        let ptx = "// fake ptx\n".to_string();
1128        let m = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1129        reg.publish(m.clone(), ptx.clone()).unwrap();
1130        let got = reg.get("matmul.f32", "1.0.0").unwrap();
1131        assert_eq!(got.0.name, "matmul.f32");
1132        assert_eq!(got.1, ptx);
1133        let listing = reg.list();
1134        assert_eq!(listing.len(), 1);
1135        assert_eq!(listing[0].version, "1.0.0");
1136    }
1137
1138    #[test]
1139    fn rejects_bad_signature() {
1140        let key = [0x42u8; 32];
1141        let reg = InMemoryRegistry::new(key);
1142        let ptx = "// fake ptx\n".to_string();
1143        let mut m = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1144        // Flip a byte in the signature.
1145        m.signature[0] ^= 0xff;
1146        match reg.publish(m, ptx) {
1147            Err(RegistryError::BadSignature(name)) => assert_eq!(name, "matmul.f32"),
1148            other => panic!("expected BadSignature, got {other:?}"),
1149        }
1150    }
1151
1152    #[test]
1153    fn rejects_digest_mismatch() {
1154        let key = [0x42u8; 32];
1155        let reg = InMemoryRegistry::new(key);
1156        let ptx = "// fake ptx\n".to_string();
1157        let m = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1158        // Publish with different PTX than what was signed.
1159        match reg.publish(m, "// different ptx\n".to_string()) {
1160            Err(RegistryError::DigestMismatch(name)) => assert_eq!(name, "matmul.f32"),
1161            other => panic!("expected DigestMismatch, got {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn rejects_duplicate_publish() {
1167        let key = [0x42u8; 32];
1168        let reg = InMemoryRegistry::new(key);
1169        let ptx = "// fake ptx\n".to_string();
1170        let m = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1171        reg.publish(m.clone(), ptx.clone()).unwrap();
1172        match reg.publish(m, ptx) {
1173            Err(RegistryError::AlreadyRegistered(key)) => {
1174                assert_eq!(key, "matmul.f32@1.0.0")
1175            }
1176            other => panic!("expected AlreadyRegistered, got {other:?}"),
1177        }
1178    }
1179
1180    #[test]
1181    fn get_returns_not_found_for_missing() {
1182        let key = [0u8; 32];
1183        let reg = InMemoryRegistry::new(key);
1184        match reg.get("nope", "0.0.0") {
1185            Err(RegistryError::NotFound(k)) => assert_eq!(k, "nope@0.0.0"),
1186            other => panic!("expected NotFound, got {other:?}"),
1187        }
1188    }
1189
1190    // --- v2 envelope: publisher + timestamp coverage --------------------
1191
1192    /// Round-trip: a manifest signed under the v2 envelope verifies
1193    /// successfully. Complementary to `publish_and_get_roundtrip`
1194    /// above but uses `verify_signature` directly so the test is
1195    /// independent of the digest + uniqueness checks the registry
1196    /// also runs.
1197    #[test]
1198    fn v2_envelope_roundtrip_verifies() {
1199        let key = [0x42u8; 32];
1200        let reg = InMemoryRegistry::new(key);
1201        let ptx = "// fake ptx\n".to_string();
1202        let m = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1203        reg.verify_signature(&m)
1204            .expect("freshly signed manifest must verify");
1205    }
1206
1207    /// Tamper-publisher: rewriting `publisher` after signing MUST
1208    /// invalidate the signature. In the v0.3.7 envelope this field
1209    /// was not covered and the signature still verified — the v2
1210    /// envelope closes that hole.
1211    #[test]
1212    fn v2_envelope_rejects_publisher_tamper() {
1213        let key = [0x42u8; 32];
1214        let reg = InMemoryRegistry::new(key);
1215        let ptx = "// fake ptx\n".to_string();
1216        let mut m = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1217        // Sanity: the freshly-signed manifest verifies under the
1218        // original publisher, so any failure below is attributable
1219        // to the post-sign tamper rather than a sign-side bug.
1220        reg.verify_signature(&m).expect("baseline must verify");
1221        m.publisher = "attacker".to_string();
1222        match reg.verify_signature(&m) {
1223            Err(RegistryError::BadSignature(name)) => assert_eq!(name, "matmul.f32"),
1224            other => panic!("expected BadSignature after publisher tamper, got {other:?}"),
1225        }
1226    }
1227
1228    /// Tamper-timestamp: rewriting `published_unix_ms` after signing
1229    /// MUST invalidate the signature. Same v0.3.7 → v2 motivation as
1230    /// `v2_envelope_rejects_publisher_tamper`.
1231    #[test]
1232    fn v2_envelope_rejects_timestamp_tamper() {
1233        let key = [0x42u8; 32];
1234        let reg = InMemoryRegistry::new(key);
1235        let ptx = "// fake ptx\n".to_string();
1236        let mut m = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1237        reg.verify_signature(&m).expect("baseline must verify");
1238        // Shift the timestamp by one millisecond — any bit change
1239        // is sufficient to break the MAC.
1240        m.published_unix_ms = m.published_unix_ms.wrapping_add(1);
1241        match reg.verify_signature(&m) {
1242            Err(RegistryError::BadSignature(name)) => assert_eq!(name, "matmul.f32"),
1243            other => panic!("expected BadSignature after timestamp tamper, got {other:?}"),
1244        }
1245    }
1246
1247    /// Canonicalisation collision: the v0.3.7 NUL-separator scheme
1248    /// produced identical signed bytes for `("a\0b", "c", ...)` and
1249    /// `("a", "b\0c", ...)`. Under the v2 length-prefixed envelope
1250    /// these MUST differ, because the `u64`-LE length prefix on each
1251    /// field disambiguates the boundary.
1252    #[test]
1253    fn v2_envelope_avoids_nul_collision() {
1254        let digest = [0u8; 32];
1255        let a = KernelManifest::new(
1256            "a\0b".to_string(),
1257            "c".to_string(),
1258            80,
1259            digest,
1260            [0u8; 32],
1261            0,
1262            "p".to_string(),
1263        );
1264        let b = KernelManifest::new(
1265            "a".to_string(),
1266            "b\0c".to_string(),
1267            80,
1268            digest,
1269            [0u8; 32],
1270            0,
1271            "p".to_string(),
1272        );
1273        let ca = a.canonical_signed_bytes();
1274        let cb = b.canonical_signed_bytes();
1275        assert_ne!(
1276            ca, cb,
1277            "v2 canonical envelope MUST disambiguate name/version field boundaries"
1278        );
1279        // Cross-check via the actual MAC, not just the canonical
1280        // bytes — a future regression that strips the length prefix
1281        // from one field but not the other could pass the byte
1282        // compare but still collide once the MAC is computed.
1283        let key = [0u8; 32];
1284        let sig_a = sign_manifest(&a, &key);
1285        let sig_b = sign_manifest(&b, &key);
1286        assert_ne!(
1287            sig_a, sig_b,
1288            "v2 signatures MUST differ across NUL-collision pair"
1289        );
1290    }
1291
1292    /// Cross-version: a manifest "signed" by the legacy v0.3.7
1293    /// canonical form (NUL separators, no publisher / timestamp
1294    /// coverage, no magic prefix) MUST NOT verify under the v2 MAC.
1295    /// We hand-roll the legacy envelope here so the test does not
1296    /// depend on any deprecated helper sticking around.
1297    #[test]
1298    fn v2_envelope_rejects_legacy_v1_signature() {
1299        use hmac::{Hmac, Mac};
1300        let key = [0x42u8; 32];
1301        let reg = InMemoryRegistry::new(key);
1302        let ptx = "// fake ptx\n";
1303        let digest = *blake3::hash(ptx.as_bytes()).as_bytes();
1304        let mut m = KernelManifest::new(
1305            "matmul.f32".to_string(),
1306            "1.0.0".to_string(),
1307            80,
1308            digest,
1309            [0u8; 32],
1310            12345,
1311            "legacy-publisher".to_string(),
1312        );
1313        // Compute the *legacy* (v0.3.7) MAC: name || 0 || version
1314        // || 0 || sm_le || digest. No publisher, no timestamp, no
1315        // magic, no length prefixes. This is what an old client or
1316        // a tampered re-publish would produce.
1317        let mut mac = <Hmac<sha2::Sha256> as Mac>::new_from_slice(&key)
1318            .expect("32-byte key is always valid HMAC-SHA256 input");
1319        mac.update(m.name.as_bytes());
1320        mac.update(b"\0");
1321        mac.update(m.version.as_bytes());
1322        mac.update(b"\0");
1323        mac.update(&m.sm_version.to_le_bytes());
1324        mac.update(&m.digest);
1325        m.signature = mac.finalize().into_bytes().into();
1326        match reg.verify_signature(&m) {
1327            Err(RegistryError::BadSignature(name)) => assert_eq!(name, "matmul.f32"),
1328            other => panic!("v1-shaped signature MUST NOT verify under v2 MAC: {other:?}"),
1329        }
1330    }
1331
1332    // --- launch_geometry: unsigned hint, back-compat ---------------------
1333
1334    /// `KernelManifest::new` defaults `launch_geometry` to `None`, and
1335    /// `with_launch_geometry` is the way to set it. This is the L3
1336    /// promotion path's source of truth — the cache reads
1337    /// `manifest.launch_geometry` and falls back to `(0, 0)` on `None`.
1338    #[test]
1339    fn launch_geometry_defaults_none_and_builder_sets_it() {
1340        let digest = [0u8; 32];
1341        let m = KernelManifest::new(
1342            "matmul.f32".to_string(),
1343            "1.0.0".to_string(),
1344            80,
1345            digest,
1346            [0u8; 32],
1347            0,
1348            "p".to_string(),
1349        );
1350        assert_eq!(
1351            m.launch_geometry, None,
1352            "new() must default geometry to None"
1353        );
1354        let m = m.with_launch_geometry(Some((8, 128)));
1355        assert_eq!(m.launch_geometry, Some((8, 128)));
1356        // Clearing back to None is supported.
1357        assert_eq!(m.with_launch_geometry(None).launch_geometry, None);
1358    }
1359
1360    /// Geometry rides OUTSIDE the v2 HMAC envelope: setting (or changing)
1361    /// it must NOT change the canonical signed bytes, so a manifest signed
1362    /// without geometry still verifies after geometry is attached. This is
1363    /// what lets the L3 promotion path carry geometry without a
1364    /// `twasm-kmf-v2` -> `-v3` format break.
1365    #[test]
1366    fn launch_geometry_is_unsigned_and_does_not_affect_mac() {
1367        let key = [0x42u8; 32];
1368        let reg = InMemoryRegistry::new(key);
1369        let ptx = "// fake ptx\n".to_string();
1370        // Sign WITHOUT geometry (the historical v2 flow).
1371        let signed = signed_manifest("matmul.f32", "1.0.0", &ptx, &key);
1372        let bytes_before = signed.canonical_signed_bytes();
1373        // Attach geometry AFTER signing.
1374        let with_geo = signed.with_launch_geometry(Some((8, 128)));
1375        let bytes_after = with_geo.canonical_signed_bytes();
1376        assert_eq!(
1377            bytes_before, bytes_after,
1378            "launch_geometry must not alter the canonical signed bytes"
1379        );
1380        // The original (geometry-less) signature still verifies.
1381        reg.verify_signature(&with_geo)
1382            .expect("manifest must still verify after attaching unsigned geometry hint");
1383    }
1384
1385    /// Round-trip: a geometry-bearing manifest survives the on-disk
1386    /// bincode codec (the same `(manifest, ptx)` envelope `DiskRegistry`
1387    /// persists), and a blob serialized WITHOUT the field (older format)
1388    /// deserializes with `launch_geometry == None` thanks to
1389    /// `#[serde(default)]`.
1390    #[test]
1391    fn launch_geometry_round_trips_and_old_blobs_default_none() {
1392        let key = [0x42u8; 32];
1393        let ptx = "// fake ptx\n".to_string();
1394        let m =
1395            signed_manifest("matmul.f32", "1.0.0", &ptx, &key).with_launch_geometry(Some((4, 256)));
1396
1397        // Round-trip through the real disk codec.
1398        let blob: ManifestBlob = (m.clone(), ptx.clone());
1399        let encoded = encode_manifest_blob(&blob).expect("encode");
1400        let (decoded, decoded_ptx) = decode_manifest_blob(&encoded).expect("decode");
1401        assert_eq!(decoded.launch_geometry, Some((4, 256)));
1402        assert_eq!(decoded_ptx, ptx);
1403        // And the signature still verifies after the codec round-trip.
1404        verify_manifest_signature(&decoded, &key).expect("decoded manifest must verify");
1405
1406        // Simulate an OLD blob that predates the field by serializing a
1407        // manifest whose geometry is None — it must decode to None, not
1408        // error, proving `#[serde(default)]` keeps old blobs readable.
1409        let old = signed_manifest("conv2d.f32", "2.0.0", &ptx, &key);
1410        assert_eq!(old.launch_geometry, None);
1411        let old_blob: ManifestBlob = (old, ptx.clone());
1412        let old_encoded = encode_manifest_blob(&old_blob).expect("encode old");
1413        let (old_decoded, _) = decode_manifest_blob(&old_encoded).expect("decode old");
1414        assert_eq!(
1415            old_decoded.launch_geometry, None,
1416            "geometry-less blob must deserialize to None"
1417        );
1418        verify_manifest_signature(&old_decoded, &key)
1419            .expect("geometry-less (old-shape) manifest must still verify");
1420    }
1421}