Skip to main content

rustev_eval/
resolve.rs

1//! Resolving retained items to typed documents (spec 004, 3.1.6 and 3.1.7).
2//!
3//! The host owns storage; Rustev checks what the host returns. Expiry is
4//! decided at the caller's `now_ms` before any byte is resolved and takes
5//! precedence over erasure. External bytes come from a synchronous,
6//! fallible [`Resolver`] bounded before allocation by the host and checked
7//! again here; a resolver error or panic is `inaccessible`, never `missing`
8//! or `available`. Bytes must be the record canonical document the digest
9//! names: a digest mismatch or invalid JSON is `corrupt`; intact bytes of the
10//! wrong schema, shape, form or identity are `mismatched`.
11
12use std::panic::{AssertUnwindSafe, catch_unwind};
13
14use rustev_contract::bounded::{BoundError, ParseError};
15use rustev_contract::canonical::tagged_digest;
16use rustev_contract::replay::MAX_RESOLVED_BYTES;
17use rustev_contract::retention::{Availability, Retention, RetentionItem};
18use rustev_contract::time::Timestamp;
19use rustev_contract::{Document, DocumentError, schema};
20
21/// What a host returns for an external reference.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Resolution {
24    /// At most the `max_bytes` asked for.
25    Bytes(Vec<u8>),
26    Missing,
27    /// Erased before expiry.
28    Erased,
29    /// Refused, failed or unreachable.
30    Inaccessible,
31}
32
33/// The host's synchronous byte provider. `reference` is opaque: it is never
34/// an instruction to fetch a URL. The host bounds its read by `max_bytes`
35/// before allocating.
36pub trait Resolver {
37    fn resolve(&self, reference: &str, max_bytes: usize) -> Resolution;
38}
39
40/// A host with no external store: every external item is inaccessible.
41pub struct NoExternal;
42
43impl Resolver for NoExternal {
44    fn resolve(&self, _reference: &str, _max_bytes: usize) -> Resolution {
45        Resolution::Inaccessible
46    }
47}
48
49/// Why an item cannot contribute.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ItemFailure {
52    Unavailable(Availability),
53    /// Beyond the per-case resolved-byte cap or the document's own parse
54    /// bounds: explicitly incomparable, never truncated.
55    Oversized,
56}
57
58/// The per-case budget of resolved bytes, external items included.
59#[derive(Debug)]
60pub struct Budget {
61    remaining: usize,
62}
63
64impl Default for Budget {
65    fn default() -> Self {
66        Budget {
67            remaining: MAX_RESOLVED_BYTES,
68        }
69    }
70}
71
72impl Budget {
73    pub fn remaining(&self) -> usize {
74        self.remaining
75    }
76}
77
78/// Resolve and check one item as a `T` document at `now`.
79pub fn resolve_item<T>(
80    item: &RetentionItem,
81    now: Timestamp,
82    resolver: &dyn Resolver,
83    budget: &mut Budget,
84) -> Result<T, ItemFailure>
85where
86    T: Document + PartialEq,
87{
88    let bytes = item_bytes(item, now, resolver, budget)?;
89    check_bytes::<T>(item, &bytes)
90}
91
92/// The item's bytes, charged to `budget`, before any content check.
93pub fn item_bytes(
94    item: &RetentionItem,
95    now: Timestamp,
96    resolver: &dyn Resolver,
97    budget: &mut Budget,
98) -> Result<Vec<u8>, ItemFailure> {
99    let unavailable = |a| Err(ItemFailure::Unavailable(a));
100    if now >= item.retention.expires_at() {
101        return unavailable(Availability::Expired);
102    }
103    let bytes = match &item.retention {
104        Retention::DigestOnly { .. } => return unavailable(Availability::Missing),
105        Retention::Retained { bytes, .. } => bytes.as_bytes().to_vec(),
106        Retention::External { reference, .. } => {
107            let max = budget.remaining;
108            match catch_unwind(AssertUnwindSafe(|| resolver.resolve(reference, max))) {
109                Ok(Resolution::Bytes(b)) => b,
110                Ok(Resolution::Missing) => return unavailable(Availability::Missing),
111                Ok(Resolution::Erased) => return unavailable(Availability::Erased),
112                Ok(Resolution::Inaccessible) | Err(_) => {
113                    return unavailable(Availability::Inaccessible);
114                }
115            }
116        }
117    };
118    // Checked again whatever the host promised.
119    if bytes.len() > budget.remaining {
120        return Err(ItemFailure::Oversized);
121    }
122    budget.remaining -= bytes.len();
123    Ok(bytes)
124}
125
126/// Check resolved bytes against the item's declaration and parse them.
127pub fn check_bytes<T>(item: &RetentionItem, bytes: &[u8]) -> Result<T, ItemFailure>
128where
129    T: Document + PartialEq,
130{
131    let fail = |a| Err(ItemFailure::Unavailable(a));
132    if tagged_digest(&item.document_schema, bytes) != item.retention.digest().as_str() {
133        return fail(Availability::Corrupt);
134    }
135    let version_3 = matches!(
136        (T::SCHEMA, item.document_schema.as_str()),
137        (schema::PLAN, schema::PLAN_V3)
138            | (schema::RUN, schema::RUN_V3)
139            | (schema::SNAPSHOT, schema::SNAPSHOT_V2)
140    );
141    if item.document_schema != T::SCHEMA && !version_3 {
142        return fail(Availability::Mismatched);
143    }
144    let doc = match T::parse(bytes) {
145        Ok(d) => d,
146        Err(DocumentError::Parse(ParseError::Bound(b))) => {
147            return match b {
148                BoundError::Syntax { .. }
149                | BoundError::InvalidUtf8 { .. }
150                | BoundError::TrailingData { .. }
151                | BoundError::Empty => fail(Availability::Corrupt),
152                BoundError::TooLarge { .. }
153                | BoundError::TooDeep { .. }
154                | BoundError::StringTooLong { .. }
155                | BoundError::CollectionTooLong { .. }
156                | BoundError::TooManyValues { .. }
157                | BoundError::NumberTooLong { .. } => Err(ItemFailure::Oversized),
158                BoundError::FractionalNumber { .. } | BoundError::DuplicateKey { .. } => {
159                    fail(Availability::Mismatched)
160                }
161            };
162        }
163        Err(_) => return fail(Availability::Mismatched),
164    };
165    if matches!(T::SCHEMA, schema::PLAN | schema::RUN | schema::SNAPSHOT)
166        && doc.schema() != item.document_schema
167    {
168        return fail(Availability::Mismatched);
169    }
170    // Exactly the record canonical form, so the digest names the document.
171    match doc.record_canonical() {
172        Ok(c) if c == bytes => {}
173        _ => return fail(Availability::Mismatched),
174    }
175    if item
176        .identity
177        .as_ref()
178        .is_some_and(|id| id != item.retention.digest())
179    {
180        return fail(Availability::Mismatched);
181    }
182    Ok(doc)
183}