Skip to main content

behavior_contracts/
envelope.rs

1//! envelope — validate_envelope (spec-version fail-closed check).
2//!
3//! Version must be `"<major>.<minor>"` (two integers). major must equal the
4//! supported major; minor must be <= supported minor (minor is additive).
5//! Unknown/out-of-range versions are loudly rejected.
6
7use serde_json::Value as J;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum EnvelopeFailureCode {
11    MissingVersion,
12    MalformedVersion,
13    UnsupportedMajor,
14    UnsupportedMinor,
15}
16
17impl EnvelopeFailureCode {
18    pub fn as_str(self) -> &'static str {
19        match self {
20            EnvelopeFailureCode::MissingVersion => "MISSING_VERSION",
21            EnvelopeFailureCode::MalformedVersion => "MALFORMED_VERSION",
22            EnvelopeFailureCode::UnsupportedMajor => "UNSUPPORTED_MAJOR",
23            EnvelopeFailureCode::UnsupportedMinor => "UNSUPPORTED_MINOR",
24        }
25    }
26}
27
28#[derive(Debug, Clone)]
29pub struct EnvelopeFailure {
30    pub code: EnvelopeFailureCode,
31    pub message: String,
32}
33
34impl std::fmt::Display for EnvelopeFailure {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{}: {}", self.code.as_str(), self.message)
37    }
38}
39impl std::error::Error for EnvelopeFailure {}
40
41fn fail<T>(code: EnvelopeFailureCode, message: impl Into<String>) -> Result<T, EnvelopeFailure> {
42    Err(EnvelopeFailure {
43        code,
44        message: message.into(),
45    })
46}
47
48fn parse_version(version: &str, label: &str) -> Result<(u64, u64), EnvelopeFailure> {
49    let parts: Vec<&str> = version.split('.').collect();
50    if parts.len() != 2 {
51        return fail(
52            EnvelopeFailureCode::MalformedVersion,
53            format!("{label}: version '{version}' must be '<major>.<minor>'"),
54        );
55    }
56    let is_int = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
57    if !is_int(parts[0]) || !is_int(parts[1]) {
58        return fail(
59            EnvelopeFailureCode::MalformedVersion,
60            format!("{label}: version '{version}' must be two integers '<major>.<minor>'"),
61        );
62    }
63    Ok((parts[0].parse().unwrap(), parts[1].parse().unwrap()))
64}
65
66/// Validate an envelope's spec version against `supported` (`"<major>.<minor>"`).
67///
68/// `field` defaults to `"specVersion"`; `label` defaults to `"envelope"`.
69pub fn validate_envelope(
70    envelope: &J,
71    supported: &str,
72    field: Option<&str>,
73    label: Option<&str>,
74) -> Result<(), EnvelopeFailure> {
75    let field = field.unwrap_or("specVersion");
76    let label = label.unwrap_or("envelope");
77    let obj = match envelope {
78        J::Object(o) => o,
79        _ => {
80            return fail(
81                EnvelopeFailureCode::MissingVersion,
82                format!("{label}: not an object"),
83            )
84        }
85    };
86    let raw = match obj.get(field) {
87        Some(J::String(s)) => s,
88        _ => {
89            return fail(
90                EnvelopeFailureCode::MissingVersion,
91                format!("{label}: missing string '{field}'"),
92            )
93        }
94    };
95
96    let (gmaj, gmin) = parse_version(raw, label)?;
97    let (wmaj, wmin) = parse_version(supported, "supported")?;
98
99    if gmaj != wmaj {
100        return fail(
101            EnvelopeFailureCode::UnsupportedMajor,
102            format!("{label}: version {raw} has major {gmaj} but runtime supports major {wmaj}"),
103        );
104    }
105    if gmin > wmin {
106        return fail(
107            EnvelopeFailureCode::UnsupportedMinor,
108            format!("{label}: version {raw} has minor {gmin} > supported minor {wmin}"),
109        );
110    }
111    Ok(())
112}