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(®, m, ptx).expect("publish");
1082 }
1083
1084 let got = KernelRegistry::get(®, "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(®, 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}