Skip to main content

vole_document/materialize/
mod.rs

1//! Deterministic materialization.
2//!
3//! The materializer is deliberately boring: it evaluates a bounded program,
4//! checks the reconstructed length, and checks the archival digest. It never
5//! searches, guesses, optimizes, or invokes external tools.
6
7pub mod observation;
8pub mod seek;
9
10use crate::container::{Descriptor, ObjectSource, ParsedDescriptor};
11use crate::error::{Error, Result};
12use crate::integrity::{sha256, to_hex};
13use crate::limits::Limits;
14use crate::store::{NullResolver, ObjectResolver};
15
16/// Decode every entropy channel in table order, each against the model it
17/// references. Each channel's model must already have been cross-validated by
18/// `Descriptor::parse`.
19#[cfg(feature = "rans")]
20fn decode_channels(d: &Descriptor, limits: Limits) -> Result<Vec<Vec<u8>>> {
21    use crate::entropy::rans::{Capsule, decode_channel};
22
23    let mut channels: Vec<Vec<u8>> = Vec::with_capacity(d.channels.len());
24    for channel in &d.channels {
25        let model = d.models.get(channel.model_id as usize).ok_or_else(|| {
26            Error::invalid_model(format!(
27                "entropy channel references missing model {}",
28                channel.model_id
29            ))
30        })?;
31        let capsule = Capsule {
32            initial_state: channel.initial_state,
33            payload: channel.payload.clone(),
34            symbol_count: channel.symbol_count,
35            decoded_length: channel.decoded_length,
36        };
37        channels.push(decode_channel(model, &capsule, limits)?);
38    }
39    Ok(channels)
40}
41
42/// Without the `rans` feature there is no entropy decoder. A descriptor with no
43/// channels is still exactly materializable (the RAW/RLE floor); one that
44/// declares channels is refused as an explicit capability limit rather than
45/// silently reinterpreted.
46#[cfg(not(feature = "rans"))]
47fn decode_channels(d: &Descriptor, _limits: Limits) -> Result<Vec<Vec<u8>>> {
48    if d.channels.is_empty() {
49        Ok(Vec::new())
50    } else {
51        Err(Error::unsupported_feature(
52            "this build was compiled without the `rans` feature",
53        ))
54    }
55}
56
57/// Resolve every object once, verifying referenced ids and lengths, then hand
58/// the DRA the same plain object vector the standalone path uses.
59fn resolve_objects<R: ObjectResolver + ?Sized>(
60    d: &Descriptor,
61    resolver: &R,
62) -> Result<Vec<Vec<u8>>> {
63    let mut out: Vec<Vec<u8>> = Vec::with_capacity(d.objects.len());
64    for (i, src) in d.objects.iter().enumerate() {
65        match src {
66            ObjectSource::Inline(bytes) => out.push(bytes.clone()),
67            ObjectSource::External { id, len } => {
68                let bytes = resolver.get(id, *len)?;
69                if bytes.len() as u64 != *len {
70                    return Err(Error::integrity_mismatch(format!(
71                        "external object {i} ({id}) has {} bytes, EXTERNAL_REF declared {len}",
72                        bytes.len()
73                    )));
74                }
75                out.push(bytes);
76            }
77        }
78    }
79    Ok(out)
80}
81
82/// Resolve every object, **moving** inline payloads out of `d` instead of
83/// cloning them.
84///
85/// Byte-for-byte equivalent to [`resolve_objects`] except that an
86/// [`ObjectSource::Inline`] payload is taken (leaving an empty `Vec` behind)
87/// rather than duplicated. The direct build's exactness proof holds the source,
88/// the serialized authority, the parsed descriptor, and the reconstructed
89/// output at once; cloning the source-sized inline object would add a fifth
90/// resident copy at the peak. It is safe here because the caller owns `d` and
91/// never reads its now-empty inline objects again.
92fn take_objects<R: ObjectResolver + ?Sized>(
93    d: &mut Descriptor,
94    resolver: &R,
95) -> Result<Vec<Vec<u8>>> {
96    let mut out: Vec<Vec<u8>> = Vec::with_capacity(d.objects.len());
97    for (i, src) in d.objects.iter_mut().enumerate() {
98        match src {
99            ObjectSource::Inline(bytes) => out.push(std::mem::take(bytes)),
100            ObjectSource::External { id, len } => {
101                let bytes = resolver.get(id, *len)?;
102                if bytes.len() as u64 != *len {
103                    return Err(Error::integrity_mismatch(format!(
104                        "external object {i} ({id}) has {} bytes, EXTERNAL_REF declared {len}",
105                        bytes.len()
106                    )));
107                }
108                out.push(bytes);
109            }
110        }
111    }
112    Ok(out)
113}
114
115/// Materialize the exact source bytes for a parsed descriptor, **consuming** its
116/// inline object payloads.
117///
118/// Byte-identical to [`materialize`] (same decoder, same length and SHA-256
119/// checks); it differs only in that it moves the inline object bytes rather than
120/// cloning them. A caller that owns the [`ParsedDescriptor`] and does not need
121/// its objects again pays one fewer source-sized copy at the peak. Used by the
122/// encoder court's decode-before-commit proof and by the direct-build ingest
123/// verification, both of which hold the source and the authority simultaneously.
124pub(crate) fn materialize_in_place(
125    parsed: &mut ParsedDescriptor,
126    limits: Limits,
127) -> Result<Vec<u8>> {
128    let d = &mut parsed.descriptor;
129    let channels = decode_channels(d, limits)?;
130    let objects = take_objects(d, &NullResolver)?;
131
132    let out = d.program.eval(&objects, &channels, limits)?;
133    if out.len() as u64 != d.source_len {
134        return Err(Error::reconstruction_mismatch(format!(
135            "materialized {} bytes but {} were declared",
136            out.len(),
137            d.source_len
138        )));
139    }
140    let digest = sha256(&out);
141    if digest != d.source_sha256 {
142        return Err(Error::integrity_mismatch(format!(
143            "materialized SHA-256 {} != declared {}",
144            to_hex(&digest),
145            to_hex(&d.source_sha256)
146        )));
147    }
148    Ok(out)
149}
150
151/// Materialize the exact source bytes for a parsed descriptor, resolving any
152/// external object references through `resolver`.
153///
154/// Enforces, in order: program bounds (via `eval`), reconstructed length, and
155/// whole-source SHA-256. The DRA is unchanged: it consumes the same plain
156/// object vector the standalone path uses.
157pub fn materialize_with<R: ObjectResolver>(
158    parsed: &ParsedDescriptor,
159    resolver: &R,
160    limits: Limits,
161) -> Result<Vec<u8>> {
162    let d = &parsed.descriptor;
163
164    let channels = decode_channels(d, limits)?;
165    let objects = resolve_objects(d, resolver)?;
166
167    let out = d.program.eval(&objects, &channels, limits)?;
168    if out.len() as u64 != d.source_len {
169        return Err(Error::reconstruction_mismatch(format!(
170            "materialized {} bytes but {} were declared",
171            out.len(),
172            d.source_len
173        )));
174    }
175    let digest = sha256(&out);
176    if digest != d.source_sha256 {
177        return Err(Error::integrity_mismatch(format!(
178            "materialized SHA-256 {} != declared {}",
179            to_hex(&digest),
180            to_hex(&d.source_sha256)
181        )));
182    }
183    Ok(out)
184}
185
186/// Materialize the exact source bytes for a parsed descriptor.
187///
188/// Standalone entry point: no resolver. Succeeds iff there are no external
189/// references; a descriptor with an [`ObjectSource::External`] object errors
190/// [`crate::ErrorClass::MissingExternalObject`].
191pub fn materialize(parsed: &ParsedDescriptor, limits: Limits) -> Result<Vec<u8>> {
192    materialize_with(parsed, &NullResolver, limits)
193}
194
195/// Parse and materialize in one step, resolving external objects through
196/// `resolver`.
197pub fn decode_to_bytes_with<R: ObjectResolver>(
198    bytes: &[u8],
199    resolver: &R,
200    limits: Limits,
201) -> Result<(Vec<u8>, ParsedDescriptor)> {
202    let parsed = Descriptor::parse(bytes, limits)?;
203    let out = materialize_with(&parsed, resolver, limits)?;
204    Ok((out, parsed))
205}
206
207/// Parse and materialize in one step (no resolver).
208pub fn decode_to_bytes(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, ParsedDescriptor)> {
209    decode_to_bytes_with(bytes, &NullResolver, limits)
210}
211
212/// The result of a deep verification.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct VerifyReport {
215    /// Reconstructed source length.
216    pub source_len: u64,
217    /// Lower-case hex SHA-256 of the reconstructed source.
218    pub sha256_hex: String,
219    /// Number of raw byte objects in the descriptor.
220    pub object_count: usize,
221    /// Number of DRA instructions in the reconstruction program.
222    pub graph_ops: usize,
223}
224
225/// Deep verification: parse, materialize, and check the archival digest.
226pub fn verify(bytes: &[u8], limits: Limits) -> Result<VerifyReport> {
227    let (out, parsed) = decode_to_bytes(bytes, limits)?;
228    Ok(VerifyReport {
229        source_len: out.len() as u64,
230        sha256_hex: to_hex(&sha256(&out)),
231        object_count: parsed.descriptor.objects.len(),
232        graph_ops: parsed.descriptor.program.ops.len(),
233    })
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::dra::Op;
240    use crate::{EXACTNESS_PROFILE_EXACT_BYTES, SOURCE_FORMAT_OPAQUE};
241
242    fn descriptor_for(source: &[u8]) -> Descriptor {
243        Descriptor {
244            universe: crate::container::UNIVERSE.to_string(),
245            source_format: SOURCE_FORMAT_OPAQUE,
246            format_basis: "opaque:test".to_string(),
247            models: vec![],
248            channels: vec![],
249            objects: vec![crate::container::ObjectSource::Inline(source.to_vec())],
250            program: crate::dra::Program::new(vec![Op::EmitObject { object_id: 0 }]),
251            observation_index: None,
252            seek_directory: false,
253            checkpoints: None,
254            source_sha256: sha256(source),
255            source_len: source.len() as u64,
256        }
257    }
258
259    #[test]
260    fn exact_roundtrip() {
261        let source = b"exact bytes must survive";
262        let d = descriptor_for(source);
263        let (bytes, _) = d.serialize().unwrap();
264        let (out, _) = decode_to_bytes(&bytes, Limits::DEFAULT).unwrap();
265        assert_eq!(out, source);
266        let _ = EXACTNESS_PROFILE_EXACT_BYTES;
267    }
268
269    #[test]
270    fn detects_digest_mismatch() {
271        let source = b"abcdef";
272        let mut d = descriptor_for(source);
273        d.source_sha256[0] ^= 0xFF;
274        let (bytes, _) = d.serialize().unwrap();
275        let e = decode_to_bytes(&bytes, Limits::DEFAULT).unwrap_err();
276        assert_eq!(e.class(), crate::ErrorClass::IntegrityMismatch);
277    }
278
279    #[test]
280    fn materialize_in_place_matches_and_empties_inline_objects() {
281        let source = b"in-place must reconstruct identical bytes".repeat(64);
282        let d = descriptor_for(&source);
283        let (bytes, _) = d.serialize().unwrap();
284
285        let mut parsed = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
286        let out = materialize_in_place(&mut parsed, Limits::DEFAULT).unwrap();
287        assert_eq!(out, source, "in-place output must equal the source");
288        // The inline payloads were moved out (the peak-saving property).
289        assert!(
290            parsed
291                .descriptor
292                .objects
293                .iter()
294                .all(|o| o.as_inline().is_some_and(|b| b.is_empty())),
295            "in-place materialization must take, not clone, inline objects"
296        );
297        // Fail closed if reused: the descriptor can no longer name its object.
298        assert!(materialize_in_place(&mut parsed, Limits::DEFAULT).is_err());
299    }
300}