Skip to main content

urna_format/sections/blob/
refs.rs

1//! blob_refs (0x14): the content-hash reference table for out-of-line or
2//! inlined media blobs (AV1 streams, AVIF images, PDFs, slide scans).
3//!
4//! OPTIONAL and EXCLUDED from content_hash. Entries are addressed by
5//! ordinal from the blob_span_overlay (0x16) `blob_ref_index` column, so
6//! entry ORDER is the contract: encode preserves input order, decode
7//! returns it unchanged, and two builds of the same table are
8//! byte-identical.
9//!
10//! the record mirrors urna-ingest's `BlobRef`: a raw 32-byte sha-256 of
11//! the original bytes, a uri hint, the original byte length, and whether
12//! the heavy bytes are inlined in this .urna (self-contained) or stay
13//! out-of-line (catalog sidecar).
14//!
15//! wire encoding: `raw` (self-describing payload, no compression).
16//! all integers le.
17
18use crate::bytes::{array32, le_u32, le_u64};
19use crate::error::UrnaError;
20use crate::layout::SECTION_BLOB_REFS;
21
22pub const BLOB_REFS_PAYLOAD_VERSION: u32 = 1;
23
24/// smallest possible encoded entry: 32 hash + 4 uri-len + 8 byte-len + 1 flag.
25/// used to bound the claimed entry count against the physical payload
26/// BEFORE any allocation, so a hostile count never triggers a huge alloc.
27const MIN_ENTRY_SIZE: usize = 32 + 4 + 8 + 1;
28
29/// one row of the 0x14 table. `content_hash` is the raw sha-256 of the
30/// original blob bytes (a catalog citation can be proven across the
31/// reference boundary); `original_uri` is a reopen hint; `inlined` says
32/// whether the bytes live inside this .urna.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct BlobRefRecord {
35    pub content_hash: [u8; 32],
36    pub original_uri: String,
37    pub byte_len: u64,
38    pub inlined: bool,
39}
40
41fn malformed(reason: impl Into<String>) -> UrnaError {
42    UrnaError::MalformedSectionPayload {
43        section_id: SECTION_BLOB_REFS,
44        reason: reason.into(),
45    }
46}
47
48/// encode `records` into the 0x14 payload. deterministic: same records in
49/// the same order, same bytes.
50pub fn encode_blob_refs(records: &[BlobRefRecord]) -> Result<Vec<u8>, UrnaError> {
51    let mut size = 12;
52    for r in records {
53        size += MIN_ENTRY_SIZE + r.original_uri.len();
54    }
55    let mut out = Vec::with_capacity(size);
56    out.extend_from_slice(&BLOB_REFS_PAYLOAD_VERSION.to_le_bytes());
57    out.extend_from_slice(&(records.len() as u64).to_le_bytes());
58    for r in records {
59        out.extend_from_slice(&r.content_hash);
60        out.extend_from_slice(&(r.original_uri.len() as u32).to_le_bytes());
61        out.extend_from_slice(r.original_uri.as_bytes());
62        out.extend_from_slice(&r.byte_len.to_le_bytes());
63        out.push(u8::from(r.inlined));
64    }
65    Ok(out)
66}
67
68/// decode the payload back to records. typed errors on truncation, a
69/// version mismatch, or a hostile count/uri claim; never panics.
70pub fn decode_blob_refs(bytes: &[u8]) -> Result<Vec<BlobRefRecord>, UrnaError> {
71    let mut cur = Cursor::new(bytes);
72    let version = cur.u32()?;
73    if version != BLOB_REFS_PAYLOAD_VERSION {
74        return Err(UrnaError::UnsupportedSectionVersion {
75            section_id: SECTION_BLOB_REFS,
76            version,
77        });
78    }
79    let n = cur.u64()? as usize;
80    // bound the claim against the physical payload before allocating:
81    // every entry costs at least MIN_ENTRY_SIZE bytes.
82    if n > cur.remaining() / MIN_ENTRY_SIZE {
83        return Err(malformed("blob_refs: entry count exceeds payload"));
84    }
85    let mut records = Vec::with_capacity(n);
86    for _ in 0..n {
87        let content_hash: [u8; 32] = array32(cur.take(32)?)?;
88        let uri_len = cur.u32()? as usize;
89        if uri_len > cur.remaining() {
90            return Err(malformed("blob_refs: uri length exceeds payload"));
91        }
92        let uri_bytes = cur.take(uri_len)?;
93        let original_uri = std::str::from_utf8(uri_bytes)
94            .map_err(|_| malformed("blob_refs: uri is not utf-8"))?
95            .to_string();
96        let byte_len = cur.u64()?;
97        let inlined = match cur.u8()? {
98            0 => false,
99            1 => true,
100            other => return Err(malformed(format!("blob_refs: bad inlined flag {}", other))),
101        };
102        records.push(BlobRefRecord {
103            content_hash,
104            original_uri,
105            byte_len,
106            inlined,
107        });
108    }
109    if cur.pos != bytes.len() {
110        return Err(malformed("trailing bytes after records"));
111    }
112    Ok(records)
113}
114
115/// light cursor over the payload. every read is bounds-checked and
116/// returns a typed error, never a panic on a hostile mmap.
117struct Cursor<'a> {
118    buf: &'a [u8],
119    pos: usize,
120}
121
122impl<'a> Cursor<'a> {
123    fn new(buf: &'a [u8]) -> Self {
124        Self { buf, pos: 0 }
125    }
126    fn remaining(&self) -> usize {
127        self.buf.len() - self.pos
128    }
129    fn take(&mut self, n: usize) -> Result<&'a [u8], UrnaError> {
130        if n > self.remaining() {
131            return Err(malformed("unexpected EOF"));
132        }
133        let s = &self.buf[self.pos..self.pos + n];
134        self.pos += n;
135        Ok(s)
136    }
137    fn u8(&mut self) -> Result<u8, UrnaError> {
138        Ok(self.take(1)?[0])
139    }
140    fn u32(&mut self) -> Result<u32, UrnaError> {
141        le_u32(self.take(4)?)
142    }
143    fn u64(&mut self) -> Result<u64, UrnaError> {
144        le_u64(self.take(8)?)
145    }
146}