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}