Skip to main content

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}