qualia_core_db/sparql_library/immersive/asset_registry.rs
1//! QISP dense-asset registry — generation-safe, fail-closed handles to
2//! content-addressed dense assets (plan §3.4, §3.6, §10.1 QISP-R03/R04).
3//!
4//! # Security contract (read before touching this file)
5//!
6//! A [`DenseAssetRef`] is a **validated, process-local handle**. It carries only
7//! numeric fields — a content-derived 60-bit token, a generation number, a section
8//! kind, a byte offset, a byte length, and a digest prefix. **No Rust address, GPU
9//! buffer pointer, or unchecked file offset is ever stored in it or derivable from
10//! it** (plan §2.2 item 5, §14 non-goals, QISP-R04). The public RDF term is an
11//! absolute IRI; it is resolved *to* one of these handles internally — never the
12//! other way around.
13//!
14//! [`DenseAssetRegistry::resolve`] **fails closed**: a forged token, a tampered
15//! offset/length, or a stale generation returns a named [`QispError`] and never
16//! fabricates a record. It never panics on a bad handle. This is a §15
17//! security-critical requirement.
18
19use super::value::QispError;
20
21/// The dense-asset section kinds (plan §3.4 / §3.6 representation table).
22#[repr(u8)]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum SectionKind {
25 /// A render/exchange mesh (glTF/GLB or `.10d` mesh section).
26 Mesh = 0,
27 /// A Tensor10D buffer/section.
28 Tensor10D = 1,
29 /// A GeoSPARQL WKT/GML geometry literal payload.
30 Wkt = 2,
31 /// A trajectory (time-parameterised path).
32 Trajectory = 3,
33 /// A bounding-volume hierarchy / spatial index section.
34 Bvh = 4,
35 /// An opaque raw byte section (media type carried in the RDF descriptor).
36 Raw = 5,
37}
38
39/// Hard capacity ceiling for a single registry. The registry is a *cold* structure
40/// (query/endpoint scoped), so a bounded `Vec` is used, but it never grows past
41/// this — over-capacity insertion fails with [`QispError::BudgetExceeded`] rather
42/// than allocating without bound (plan §6.2 "fixed-capacity result-handle table").
43pub const MAX_ASSETS: usize = 4096;
44
45/// A validated, generation-safe, **process-local** handle to a dense asset.
46///
47/// All fields are numeric and private; only the accessors below are public, so no
48/// caller can inject a raw address. This is the record resolved from a public
49/// absolute-IRI RDF term (plan §3.4).
50#[repr(C)]
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct DenseAssetRef {
53 /// 60-bit content-derived token (top 4 bits reserved for tag bits). NOT a pointer.
54 token: u64,
55 /// Generation of the owning slot at mint time; used to reject stale reuse.
56 generation: u32,
57 /// Which kind of section this asset is.
58 section: SectionKind,
59 /// Byte offset of the section within its container. Validated on resolve.
60 offset: u64,
61 /// Byte length of the section. Validated on resolve.
62 length: u64,
63 /// Truncated content digest prefix (integrity cross-check; not the full digest).
64 digest_prefix: u64,
65}
66
67impl DenseAssetRef {
68 /// The 60-bit content token (never a memory address).
69 pub const fn token(&self) -> u64 {
70 self.token
71 }
72 /// The generation this handle was minted against.
73 pub const fn generation(&self) -> u32 {
74 self.generation
75 }
76 /// The section kind.
77 pub const fn section(&self) -> SectionKind {
78 self.section
79 }
80 /// The byte offset (validated, container-relative — not a raw pointer).
81 pub const fn offset(&self) -> u64 {
82 self.offset
83 }
84 /// The byte length.
85 pub const fn length(&self) -> u64 {
86 self.length
87 }
88 /// The truncated content digest prefix.
89 pub const fn digest_prefix(&self) -> u64 {
90 self.digest_prefix
91 }
92}
93
94/// The immutable record a registry stores for a live asset. Structurally identical
95/// to [`DenseAssetRef`]; returned by `resolve` so callers can read the *validated*
96/// fields (never a pointer).
97#[repr(C)]
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99pub struct AssetRecord {
100 token: u64,
101 generation: u32,
102 section: SectionKind,
103 offset: u64,
104 length: u64,
105 digest_prefix: u64,
106}
107
108impl AssetRecord {
109 /// The 60-bit content token.
110 pub const fn token(&self) -> u64 {
111 self.token
112 }
113 /// The current generation of this record's slot.
114 pub const fn generation(&self) -> u32 {
115 self.generation
116 }
117 /// The section kind.
118 pub const fn section(&self) -> SectionKind {
119 self.section
120 }
121 /// The byte offset.
122 pub const fn offset(&self) -> u64 {
123 self.offset
124 }
125 /// The byte length.
126 pub const fn length(&self) -> u64 {
127 self.length
128 }
129 /// The truncated content digest prefix.
130 pub const fn digest_prefix(&self) -> u64 {
131 self.digest_prefix
132 }
133 /// Mint a handle that refers to this record at its current generation.
134 pub const fn to_ref(&self) -> DenseAssetRef {
135 DenseAssetRef {
136 token: self.token,
137 generation: self.generation,
138 section: self.section,
139 offset: self.offset,
140 length: self.length,
141 digest_prefix: self.digest_prefix,
142 }
143 }
144}
145
146/// One physical slot in the registry. A slot's `generation` is monotonic across
147/// eviction + reuse, so a handle minted before a reuse is rejected as stale.
148#[derive(Debug, Clone, Copy)]
149struct Slot {
150 occupied: bool,
151 record: AssetRecord,
152}
153
154/// A bounded, generation-safe registry mapping content tokens to validated dense
155/// asset records.
156///
157/// Insertion is content-addressed and idempotent; resolution fails closed on
158/// unknown, tampered, or stale handles.
159#[derive(Debug, Default)]
160pub struct DenseAssetRegistry {
161 slots: Vec<Slot>,
162}
163
164impl DenseAssetRegistry {
165 /// A new, empty registry.
166 pub fn new() -> Self {
167 Self { slots: Vec::new() }
168 }
169
170 /// Number of currently-live (occupied) assets.
171 pub fn len(&self) -> usize {
172 self.slots.iter().filter(|s| s.occupied).count()
173 }
174
175 /// Whether the registry has no live assets.
176 pub fn is_empty(&self) -> bool {
177 self.len() == 0
178 }
179
180 /// Deterministically derive the 60-bit content token for an asset from its
181 /// section kind, offset, length, and digest prefix. Same content → same token.
182 fn derive_token(section: SectionKind, offset: u64, length: u64, digest_prefix: u64) -> u64 {
183 let mut bytes = [0u8; 25];
184 bytes[0] = section as u8;
185 bytes[1..9].copy_from_slice(&offset.to_le_bytes());
186 bytes[9..17].copy_from_slice(&length.to_le_bytes());
187 bytes[17..25].copy_from_slice(&digest_prefix.to_le_bytes());
188 // Runtime 60-bit token (top 4 bits reserved for tag bits), reusing the
189 // crate's canonical FNV-1a token generator.
190 crate::lexicon::generate_60bit_token(&bytes)
191 }
192
193 /// Insert (or return the existing handle for) a dense asset, minting a
194 /// content-derived token and a fresh generation.
195 ///
196 /// Returns [`QispError::BudgetExceeded`] if the registry is at `MAX_ASSETS`
197 /// capacity and no slot is free. Inserting content that already has a live slot
198 /// is idempotent and returns the existing handle.
199 ///
200 /// > Note: the plan sketches this as `-> DenseAssetRef`; it is returned as a
201 /// > `Result` here so the hard capacity bound can fail closed instead of
202 /// > panicking or allocating without bound (plan §6.2, §12 completeness bar).
203 pub fn insert(
204 &mut self,
205 section: SectionKind,
206 offset: u64,
207 length: u64,
208 digest_prefix: u64,
209 ) -> Result<DenseAssetRef, QispError> {
210 let token = Self::derive_token(section, offset, length, digest_prefix);
211
212 // Idempotent: identical live content returns its existing handle.
213 for slot in self.slots.iter() {
214 if slot.occupied && slot.record.token == token {
215 return Ok(slot.record.to_ref());
216 }
217 }
218
219 // Reuse a freed slot, bumping its generation so old handles go stale.
220 for slot in self.slots.iter_mut() {
221 if !slot.occupied {
222 let generation = slot.record.generation.wrapping_add(1);
223 slot.record = AssetRecord {
224 token,
225 generation,
226 section,
227 offset,
228 length,
229 digest_prefix,
230 };
231 slot.occupied = true;
232 return Ok(slot.record.to_ref());
233 }
234 }
235
236 // Otherwise append a brand-new slot at generation 1, respecting the cap.
237 if self.slots.len() >= MAX_ASSETS {
238 return Err(QispError::BudgetExceeded);
239 }
240 let record = AssetRecord {
241 token,
242 generation: 1,
243 section,
244 offset,
245 length,
246 digest_prefix,
247 };
248 self.slots.push(Slot {
249 occupied: true,
250 record,
251 });
252 Ok(record.to_ref())
253 }
254
255 /// Resolve a handle to its validated record. **Fails closed:**
256 ///
257 /// - [`QispError::UnknownAsset`] — the token is not present, or any of the
258 /// handle's section/offset/length/digest fields were tampered so they no
259 /// longer match the stored record.
260 /// - [`QispError::StaleAsset`] — the token is present but the handle's
261 /// generation is older than the slot's current generation (the slot was
262 /// evicted and reused).
263 ///
264 /// Never panics on a bad handle.
265 pub fn resolve(&self, r: &DenseAssetRef) -> Result<&AssetRecord, QispError> {
266 for slot in self.slots.iter() {
267 if slot.occupied && slot.record.token == r.token {
268 // Generation check first: a matching token with an old generation
269 // is a stale-handle reuse, reported distinctly.
270 if slot.record.generation != r.generation {
271 return Err(QispError::StaleAsset);
272 }
273 // Integrity cross-check: every carried field must match the stored
274 // record. A mutated offset/length/section/digest (token untouched)
275 // fails as unknown rather than resolving to something else.
276 if slot.record.section != r.section
277 || slot.record.offset != r.offset
278 || slot.record.length != r.length
279 || slot.record.digest_prefix != r.digest_prefix
280 {
281 return Err(QispError::UnknownAsset);
282 }
283 return Ok(&slot.record);
284 }
285 }
286 Err(QispError::UnknownAsset)
287 }
288
289 /// Evict a live asset, freeing its slot. The slot's generation is retained so a
290 /// later re-insert bumps past it, invalidating any outstanding handle.
291 ///
292 /// Fails closed the same way as [`resolve`](Self::resolve): a forged or stale
293 /// handle cannot evict a live asset.
294 pub fn evict(&mut self, r: &DenseAssetRef) -> Result<(), QispError> {
295 // Validate against the same rules as resolve before mutating.
296 let mut target: Option<usize> = None;
297 for (i, slot) in self.slots.iter().enumerate() {
298 if slot.occupied && slot.record.token == r.token {
299 if slot.record.generation != r.generation {
300 return Err(QispError::StaleAsset);
301 }
302 if slot.record.section != r.section
303 || slot.record.offset != r.offset
304 || slot.record.length != r.length
305 || slot.record.digest_prefix != r.digest_prefix
306 {
307 return Err(QispError::UnknownAsset);
308 }
309 target = Some(i);
310 break;
311 }
312 }
313 match target {
314 Some(i) => {
315 self.slots[i].occupied = false;
316 Ok(())
317 }
318 None => Err(QispError::UnknownAsset),
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use std::mem::size_of;
327
328 #[test]
329 fn ref_carries_no_address_only_numeric_fields() {
330 // Structural proof there is no pointer field: the ref is exactly the sum of
331 // its numeric fields under repr(C) (u64 + u32 + u8 + pad + u64*3 = 40).
332 assert_eq!(size_of::<DenseAssetRef>(), 40);
333 assert_eq!(size_of::<AssetRecord>(), 40);
334 // And it is Copy (no owned heap buffer behind a pointer).
335 fn assert_copy<T: Copy>() {}
336 assert_copy::<DenseAssetRef>();
337 }
338
339 #[test]
340 fn insert_then_resolve_round_trip() {
341 let mut reg = DenseAssetRegistry::new();
342 let r = reg
343 .insert(SectionKind::Mesh, 128, 4096, 0xDEAD_BEEF)
344 .unwrap();
345 assert_eq!(r.section(), SectionKind::Mesh);
346 assert_eq!(r.offset(), 128);
347 assert_eq!(r.length(), 4096);
348 assert_eq!(r.digest_prefix(), 0xDEAD_BEEF);
349
350 let rec = reg.resolve(&r).unwrap();
351 assert_eq!(rec.token(), r.token());
352 assert_eq!(rec.offset(), 128);
353 assert_eq!(rec.length(), 4096);
354 assert_eq!(reg.len(), 1);
355 }
356
357 #[test]
358 fn insert_is_idempotent_for_identical_content() {
359 let mut reg = DenseAssetRegistry::new();
360 let a = reg.insert(SectionKind::Tensor10D, 0, 400, 7).unwrap();
361 let b = reg.insert(SectionKind::Tensor10D, 0, 400, 7).unwrap();
362 assert_eq!(a, b);
363 assert_eq!(reg.len(), 1);
364 }
365
366 #[test]
367 fn mutated_token_fails_unknown() {
368 let mut reg = DenseAssetRegistry::new();
369 let r = reg.insert(SectionKind::Wkt, 16, 64, 1).unwrap();
370 // Flip the token without a matching slot -> not found.
371 let forged = DenseAssetRef {
372 token: r.token() ^ 0x1,
373 ..r
374 };
375 assert_eq!(reg.resolve(&forged), Err(QispError::UnknownAsset));
376 }
377
378 #[test]
379 fn mutated_offset_or_length_fails_unknown() {
380 let mut reg = DenseAssetRegistry::new();
381 let r = reg.insert(SectionKind::Raw, 100, 200, 0xABCD).unwrap();
382
383 // Same token, tampered offset.
384 let bad_offset = DenseAssetRef { offset: 101, ..r };
385 assert_eq!(reg.resolve(&bad_offset), Err(QispError::UnknownAsset));
386
387 // Same token, tampered length.
388 let bad_length = DenseAssetRef { length: 999, ..r };
389 assert_eq!(reg.resolve(&bad_length), Err(QispError::UnknownAsset));
390
391 // Same token, tampered section.
392 let bad_section = DenseAssetRef {
393 section: SectionKind::Bvh,
394 ..r
395 };
396 assert_eq!(reg.resolve(&bad_section), Err(QispError::UnknownAsset));
397
398 // The untampered handle still resolves.
399 assert!(reg.resolve(&r).is_ok());
400 }
401
402 #[test]
403 fn stale_handle_after_slot_reuse_fails_stale() {
404 let mut reg = DenseAssetRegistry::new();
405 let r1 = reg.insert(SectionKind::Mesh, 32, 512, 0xF00D).unwrap();
406 assert_eq!(r1.generation(), 1);
407
408 // Evict, then re-insert identical content -> same slot, bumped generation.
409 reg.evict(&r1).unwrap();
410 let r2 = reg.insert(SectionKind::Mesh, 32, 512, 0xF00D).unwrap();
411 assert_eq!(r2.token(), r1.token());
412 assert_eq!(r2.generation(), 2);
413
414 // The old handle is now stale, not fabricated.
415 assert_eq!(reg.resolve(&r1), Err(QispError::StaleAsset));
416 // The new handle resolves.
417 assert!(reg.resolve(&r2).is_ok());
418 }
419
420 #[test]
421 fn evict_rejects_forged_and_stale_handles() {
422 let mut reg = DenseAssetRegistry::new();
423 let r = reg.insert(SectionKind::Trajectory, 8, 128, 5).unwrap();
424
425 let forged = DenseAssetRef {
426 token: r.token() ^ 0xFF,
427 ..r
428 };
429 assert_eq!(reg.evict(&forged), Err(QispError::UnknownAsset));
430
431 let bad_gen = DenseAssetRef {
432 generation: r.generation() + 5,
433 ..r
434 };
435 assert_eq!(reg.evict(&bad_gen), Err(QispError::StaleAsset));
436
437 // The genuine handle still evicts.
438 assert!(reg.evict(&r).is_ok());
439 // And is unknown afterwards.
440 assert_eq!(reg.resolve(&r), Err(QispError::UnknownAsset));
441 }
442
443 #[test]
444 fn capacity_bound_is_respected() {
445 let mut reg = DenseAssetRegistry::new();
446 // Fill to capacity with distinct content (distinct digest prefixes).
447 for i in 0..MAX_ASSETS as u64 {
448 reg.insert(SectionKind::Raw, 0, 1, i).unwrap();
449 }
450 assert_eq!(reg.len(), MAX_ASSETS);
451 // One past capacity fails closed.
452 let over = reg.insert(SectionKind::Raw, 0, 1, MAX_ASSETS as u64);
453 assert_eq!(over, Err(QispError::BudgetExceeded));
454 }
455
456 #[test]
457 fn resolve_never_panics_on_empty_registry() {
458 let reg = DenseAssetRegistry::new();
459 let bogus = DenseAssetRef {
460 token: 1234,
461 generation: 1,
462 section: SectionKind::Mesh,
463 offset: 0,
464 length: 0,
465 digest_prefix: 0,
466 };
467 assert_eq!(reg.resolve(&bogus), Err(QispError::UnknownAsset));
468 }
469}