Skip to main content

runmat_execution/identity/
program.rs

1use minicbor::{Decoder, Encoder};
2use serde::{Deserialize, Serialize};
3
4use super::Digest;
5use crate::{schema::PROGRAM_REVISION_SCHEMA_V1, ContractError};
6
7const MAX_CONTRIBUTIONS: usize = 32;
8const MAX_CONTRIBUTION_NAME_BYTES: usize = 96;
9const MAX_MODE_BYTES: usize = 32;
10
11#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
12#[serde(try_from = "DomainContributionWire")]
13pub struct DomainContribution {
14    name: String,
15    digest: Digest,
16}
17
18impl DomainContribution {
19    pub fn new(name: impl Into<String>, digest: Digest) -> Result<Self, ContractError> {
20        let name = name.into();
21        validate_token(
22            "domain contribution name",
23            &name,
24            MAX_CONTRIBUTION_NAME_BYTES,
25        )?;
26        Ok(Self { name, digest })
27    }
28
29    pub fn name(&self) -> &str {
30        &self.name
31    }
32
33    pub fn digest(&self) -> &Digest {
34        &self.digest
35    }
36}
37
38#[derive(Deserialize)]
39#[serde(deny_unknown_fields)]
40struct DomainContributionWire {
41    name: String,
42    digest: Digest,
43}
44
45impl TryFrom<DomainContributionWire> for DomainContribution {
46    type Error = ContractError;
47
48    fn try_from(value: DomainContributionWire) -> Result<Self, Self::Error> {
49        Self::new(value.name, value.digest)
50    }
51}
52
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
54#[serde(try_from = "ProgramEnvironmentWire")]
55pub struct ProgramEnvironment {
56    pub semantic_schema: u32,
57    pub compiler_schema: u32,
58    pub runtime_fingerprint: Digest,
59    pub catalog_fingerprint: Digest,
60    pub compatibility_mode: String,
61}
62
63#[derive(Deserialize)]
64#[serde(deny_unknown_fields)]
65struct ProgramEnvironmentWire {
66    semantic_schema: u32,
67    compiler_schema: u32,
68    runtime_fingerprint: Digest,
69    catalog_fingerprint: Digest,
70    compatibility_mode: String,
71}
72
73impl TryFrom<ProgramEnvironmentWire> for ProgramEnvironment {
74    type Error = ContractError;
75
76    fn try_from(value: ProgramEnvironmentWire) -> Result<Self, Self::Error> {
77        Self::new(
78            value.semantic_schema,
79            value.compiler_schema,
80            value.runtime_fingerprint,
81            value.catalog_fingerprint,
82            value.compatibility_mode,
83        )
84    }
85}
86
87impl ProgramEnvironment {
88    pub fn new(
89        semantic_schema: u32,
90        compiler_schema: u32,
91        runtime_fingerprint: Digest,
92        catalog_fingerprint: Digest,
93        compatibility_mode: impl Into<String>,
94    ) -> Result<Self, ContractError> {
95        let environment = Self {
96            semantic_schema,
97            compiler_schema,
98            runtime_fingerprint,
99            catalog_fingerprint,
100            compatibility_mode: compatibility_mode.into(),
101        };
102        environment.validate()?;
103        Ok(environment)
104    }
105
106    pub fn validate(&self) -> Result<(), ContractError> {
107        if self.semantic_schema == 0 || self.compiler_schema == 0 {
108            return Err(ContractError::invalid(
109                "program environment schemas",
110                "semantic and compiler schemas must be non-zero",
111            ));
112        }
113        validate_token(
114            "compatibility mode",
115            &self.compatibility_mode,
116            MAX_MODE_BYTES,
117        )
118    }
119}
120
121#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
122#[serde(try_from = "ProgramRevisionWire")]
123pub struct ProgramRevision {
124    schema_version: u16,
125    graph_digest: Digest,
126    source_digest: Digest,
127    semantic_schema: u32,
128    compiler_schema: u32,
129    runtime_fingerprint: Digest,
130    catalog_fingerprint: Digest,
131    compatibility_mode: String,
132    domain_contributions: Vec<DomainContribution>,
133}
134
135impl ProgramRevision {
136    pub fn new(
137        graph_digest: Digest,
138        source_digest: Digest,
139        environment: ProgramEnvironment,
140    ) -> Result<Self, ContractError> {
141        environment.validate()?;
142        let revision = Self {
143            schema_version: PROGRAM_REVISION_SCHEMA_V1,
144            graph_digest,
145            source_digest,
146            semantic_schema: environment.semantic_schema,
147            compiler_schema: environment.compiler_schema,
148            runtime_fingerprint: environment.runtime_fingerprint,
149            catalog_fingerprint: environment.catalog_fingerprint,
150            compatibility_mode: environment.compatibility_mode,
151            domain_contributions: Vec::new(),
152        };
153        revision.validate()?;
154        Ok(revision)
155    }
156
157    pub fn with_domain_contribution(
158        mut self,
159        contribution: DomainContribution,
160    ) -> Result<Self, ContractError> {
161        match self
162            .domain_contributions
163            .binary_search_by(|candidate| candidate.name.cmp(&contribution.name))
164        {
165            Ok(_) => {
166                return Err(ContractError::invalid(
167                    "domain contributions",
168                    format!("duplicate contribution `{}`", contribution.name),
169                ))
170            }
171            Err(index) => self.domain_contributions.insert(index, contribution),
172        }
173        self.validate()?;
174        Ok(self)
175    }
176
177    pub fn domain_contribution(&self, name: &str) -> Option<&Digest> {
178        self.domain_contributions
179            .binary_search_by(|candidate| candidate.name.as_str().cmp(name))
180            .ok()
181            .map(|index| &self.domain_contributions[index].digest)
182    }
183
184    pub fn schema_version(&self) -> u16 {
185        self.schema_version
186    }
187
188    pub fn graph_digest(&self) -> &Digest {
189        &self.graph_digest
190    }
191
192    pub fn source_digest(&self) -> &Digest {
193        &self.source_digest
194    }
195
196    pub fn environment(&self) -> ProgramEnvironment {
197        ProgramEnvironment {
198            semantic_schema: self.semantic_schema,
199            compiler_schema: self.compiler_schema,
200            runtime_fingerprint: self.runtime_fingerprint,
201            catalog_fingerprint: self.catalog_fingerprint,
202            compatibility_mode: self.compatibility_mode.clone(),
203        }
204    }
205
206    pub fn semantic_schema(&self) -> u32 {
207        self.semantic_schema
208    }
209
210    pub fn compiler_schema(&self) -> u32 {
211        self.compiler_schema
212    }
213
214    pub fn runtime_fingerprint(&self) -> &Digest {
215        &self.runtime_fingerprint
216    }
217
218    pub fn catalog_fingerprint(&self) -> &Digest {
219        &self.catalog_fingerprint
220    }
221
222    pub fn compatibility_mode(&self) -> &str {
223        &self.compatibility_mode
224    }
225
226    pub fn domain_contributions(&self) -> &[DomainContribution] {
227        &self.domain_contributions
228    }
229
230    pub fn validate(&self) -> Result<(), ContractError> {
231        if self.schema_version != PROGRAM_REVISION_SCHEMA_V1 {
232            return Err(ContractError::UnsupportedSchema {
233                actual: self.schema_version,
234                supported: PROGRAM_REVISION_SCHEMA_V1,
235            });
236        }
237        if self.semantic_schema == 0 || self.compiler_schema == 0 {
238            return Err(ContractError::invalid(
239                "program revision schemas",
240                "semantic and compiler schemas must be non-zero",
241            ));
242        }
243        validate_token(
244            "compatibility mode",
245            &self.compatibility_mode,
246            MAX_MODE_BYTES,
247        )?;
248        if self.domain_contributions.len() > MAX_CONTRIBUTIONS {
249            return Err(ContractError::Limit {
250                field: "domain contributions",
251                limit: MAX_CONTRIBUTIONS as u64,
252            });
253        }
254        let mut previous: Option<&str> = None;
255        for contribution in &self.domain_contributions {
256            validate_token(
257                "domain contribution name",
258                &contribution.name,
259                MAX_CONTRIBUTION_NAME_BYTES,
260            )?;
261            if previous.is_some_and(|name| name >= contribution.name.as_str()) {
262                return Err(ContractError::invalid(
263                    "domain contributions",
264                    "contributions must be unique and sorted by name",
265                ));
266            }
267            previous = Some(&contribution.name);
268        }
269        Ok(())
270    }
271
272    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ContractError> {
273        self.validate()?;
274        let mut bytes = Vec::new();
275        let mut encoder = Encoder::new(&mut bytes);
276        encoder
277            .map(9)
278            .and_then(|encoder| encoder.u8(0))
279            .and_then(|encoder| encoder.u16(self.schema_version))
280            .and_then(|encoder| encoder.u8(1))
281            .and_then(|encoder| encoder.bytes(self.graph_digest.bytes()))
282            .and_then(|encoder| encoder.u8(2))
283            .and_then(|encoder| encoder.bytes(self.source_digest.bytes()))
284            .and_then(|encoder| encoder.u8(3))
285            .and_then(|encoder| encoder.u32(self.semantic_schema))
286            .and_then(|encoder| encoder.u8(4))
287            .and_then(|encoder| encoder.u32(self.compiler_schema))
288            .and_then(|encoder| encoder.u8(5))
289            .and_then(|encoder| encoder.bytes(self.runtime_fingerprint.bytes()))
290            .and_then(|encoder| encoder.u8(6))
291            .and_then(|encoder| encoder.bytes(self.catalog_fingerprint.bytes()))
292            .and_then(|encoder| encoder.u8(7))
293            .and_then(|encoder| encoder.str(&self.compatibility_mode))
294            .and_then(|encoder| encoder.u8(8))
295            .and_then(|encoder| encoder.array(self.domain_contributions.len() as u64))
296            .map_err(|error| ContractError::invalid("program revision", error.to_string()))?;
297        for contribution in &self.domain_contributions {
298            encoder
299                .array(2)
300                .and_then(|encoder| encoder.str(&contribution.name))
301                .and_then(|encoder| encoder.bytes(contribution.digest.bytes()))
302                .map_err(|error| ContractError::invalid("program revision", error.to_string()))?;
303        }
304        Ok(bytes)
305    }
306
307    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ContractError> {
308        let mut decoder = Decoder::new(bytes);
309        require_len(decoder.map(), 9, "program revision")?;
310        require_key(&mut decoder, 0)?;
311        let schema_version = decoder.u16().map_err(decode_error)?;
312        require_key(&mut decoder, 1)?;
313        let graph_digest = decode_digest(&mut decoder)?;
314        require_key(&mut decoder, 2)?;
315        let source_digest = decode_digest(&mut decoder)?;
316        require_key(&mut decoder, 3)?;
317        let semantic_schema = decoder.u32().map_err(decode_error)?;
318        require_key(&mut decoder, 4)?;
319        let compiler_schema = decoder.u32().map_err(decode_error)?;
320        require_key(&mut decoder, 5)?;
321        let runtime_fingerprint = decode_digest(&mut decoder)?;
322        require_key(&mut decoder, 6)?;
323        let catalog_fingerprint = decode_digest(&mut decoder)?;
324        require_key(&mut decoder, 7)?;
325        let compatibility_mode = decoder.str().map_err(decode_error)?.to_owned();
326        require_key(&mut decoder, 8)?;
327        let contribution_count =
328            require_bounded_len(decoder.array(), MAX_CONTRIBUTIONS, "domain contributions")?;
329        let mut domain_contributions = Vec::with_capacity(contribution_count);
330        for _ in 0..contribution_count {
331            require_len(decoder.array(), 2, "domain contribution")?;
332            let name = decoder.str().map_err(decode_error)?.to_owned();
333            let digest = decode_digest(&mut decoder)?;
334            domain_contributions.push(DomainContribution::new(name, digest)?);
335        }
336        if decoder.position() != bytes.len() {
337            return Err(ContractError::invalid(
338                "program revision",
339                "canonical encoding contains trailing data",
340            ));
341        }
342        ProgramRevision::try_from(ProgramRevisionWire {
343            schema_version,
344            graph_digest,
345            source_digest,
346            semantic_schema,
347            compiler_schema,
348            runtime_fingerprint,
349            catalog_fingerprint,
350            compatibility_mode,
351            domain_contributions,
352        })
353    }
354
355    pub fn identity_digest(&self) -> Result<Digest, ContractError> {
356        let mut framed = b"runmat-program-revision-v1\0".to_vec();
357        framed.extend(self.canonical_bytes()?);
358        Ok(Digest::sha256(framed))
359    }
360
361    pub fn canonical_identity(&self) -> String {
362        self.identity_digest()
363            .expect("validated ProgramRevision has a canonical identity")
364            .to_string()
365    }
366}
367
368fn require_key(decoder: &mut Decoder<'_>, expected: u8) -> Result<(), ContractError> {
369    let actual = decoder.u8().map_err(decode_error)?;
370    if actual != expected {
371        return Err(ContractError::invalid(
372            "program revision",
373            format!("expected field key {expected}, found {actual}"),
374        ));
375    }
376    Ok(())
377}
378
379fn require_len(
380    length: Result<Option<u64>, minicbor::decode::Error>,
381    expected: u64,
382    field: &'static str,
383) -> Result<(), ContractError> {
384    match length.map_err(decode_error)? {
385        Some(actual) if actual == expected => Ok(()),
386        Some(actual) => Err(ContractError::invalid(
387            field,
388            format!("expected {expected} entries, found {actual}"),
389        )),
390        None => Err(ContractError::invalid(
391            field,
392            "indefinite-length CBOR is not canonical",
393        )),
394    }
395}
396
397fn require_bounded_len(
398    length: Result<Option<u64>, minicbor::decode::Error>,
399    maximum: usize,
400    field: &'static str,
401) -> Result<usize, ContractError> {
402    match length.map_err(decode_error)? {
403        Some(actual) if actual <= maximum as u64 => Ok(actual as usize),
404        Some(_) => Err(ContractError::Limit {
405            field,
406            limit: maximum as u64,
407        }),
408        None => Err(ContractError::invalid(
409            field,
410            "indefinite-length CBOR is not canonical",
411        )),
412    }
413}
414
415fn decode_digest(decoder: &mut Decoder<'_>) -> Result<Digest, ContractError> {
416    let bytes = decoder.bytes().map_err(decode_error)?;
417    let bytes: [u8; 32] = bytes
418        .try_into()
419        .map_err(|_| ContractError::invalid("digest", "expected exactly 32 bytes"))?;
420    Ok(Digest::from_bytes(bytes))
421}
422
423fn decode_error(error: minicbor::decode::Error) -> ContractError {
424    ContractError::invalid("program revision", error.to_string())
425}
426
427#[derive(Deserialize)]
428#[serde(deny_unknown_fields)]
429struct ProgramRevisionWire {
430    schema_version: u16,
431    graph_digest: Digest,
432    source_digest: Digest,
433    semantic_schema: u32,
434    compiler_schema: u32,
435    runtime_fingerprint: Digest,
436    catalog_fingerprint: Digest,
437    compatibility_mode: String,
438    domain_contributions: Vec<DomainContribution>,
439}
440
441impl TryFrom<ProgramRevisionWire> for ProgramRevision {
442    type Error = ContractError;
443
444    fn try_from(value: ProgramRevisionWire) -> Result<Self, Self::Error> {
445        let revision = Self {
446            schema_version: value.schema_version,
447            graph_digest: value.graph_digest,
448            source_digest: value.source_digest,
449            semantic_schema: value.semantic_schema,
450            compiler_schema: value.compiler_schema,
451            runtime_fingerprint: value.runtime_fingerprint,
452            catalog_fingerprint: value.catalog_fingerprint,
453            compatibility_mode: value.compatibility_mode,
454            domain_contributions: value.domain_contributions,
455        };
456        revision.validate()?;
457        Ok(revision)
458    }
459}
460
461fn validate_token(field: &'static str, value: &str, max_bytes: usize) -> Result<(), ContractError> {
462    if value.is_empty()
463        || value.len() > max_bytes
464        || !value.is_ascii()
465        || value.bytes().any(|byte| {
466            !(byte.is_ascii_lowercase()
467                || byte.is_ascii_digit()
468                || matches!(byte, b'.' | b'-' | b'_'))
469        })
470    {
471        return Err(ContractError::invalid(
472            field,
473            format!(
474                "must be 1..={max_bytes} bytes of lowercase ASCII letters, digits, `.`, `-`, or `_`"
475            ),
476        ));
477    }
478    Ok(())
479}