Skip to main content

candid_core/
envelope.rs

1use crate::{Contract, ContractJsonError, ContractValidationError, Limits, RawContract};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6/// A strict semantic Contract plus namespaced, non-semantic ecosystem metadata.
7///
8/// # Extensions are outside the canonical identities
9///
10/// The extension map is side-band data by design: `contract_id` and
11/// `interface_id` are computed over the Contract alone, so adding, removing,
12/// or editing an extension never moves a semantic identity
13/// (`tests/artifact_identity.rs` pins this, and `docs/architecture.md`'s
14/// identity table records it). Only the artifact identity over the envelope
15/// *bytes* (`ArtifactKind::ContractEnvelopeJsonV1`) covers extensions,
16/// because they are in those bytes.
17///
18/// Extension names are validated fail-closed: a reverse-domain namespace
19/// (dot-separated segments of lowercase letters, digits, and hyphens)
20/// followed by `/v<integer>` with no leading zero, e.g.
21/// `org.candid-core.field-names/v1`. Values are arbitrary JSON, bounded in
22/// aggregate by `Limits::max_value_bytes`.
23///
24/// # The `org.candid-core.field-names/v1` extension (issue #152)
25///
26/// The one extension this repository itself emits, via
27/// `candid-core compile <path> --envelope`: the field-name table as an array
28/// of `[container, id, name]` triples — named labels only, sorted,
29/// deduplicated — exactly the table `TsNames::from_source_info` builds from
30/// the provenance sidecar and the TypeScript runtime's `schemaFromContract`
31/// consumes (hash-enforced, so a lying table fails closed there). The
32/// semantic Contract stores only authoritative label ids; this extension
33/// lets one self-describing document carry the names too, without touching
34/// the canonical format or its identities.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36#[serde(deny_unknown_fields)]
37pub struct ContractEnvelope {
38    contract: Contract,
39    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40    extensions: BTreeMap<String, Value>,
41}
42
43impl ContractEnvelope {
44    pub fn new(contract: Contract) -> Self {
45        Self {
46            contract,
47            extensions: BTreeMap::new(),
48        }
49    }
50
51    pub fn contract(&self) -> &Contract {
52        &self.contract
53    }
54
55    pub fn extensions(&self) -> &BTreeMap<String, Value> {
56        &self.extensions
57    }
58
59    pub fn insert_extension(
60        &mut self,
61        name: impl Into<String>,
62        value: Value,
63        limits: &Limits,
64    ) -> Result<(), ContractValidationError> {
65        self.insert_extension_with_context(name, value, &crate::RuntimeContext::new(limits.clone()))
66    }
67
68    pub fn insert_extension_with_context(
69        &mut self,
70        name: impl Into<String>,
71        value: Value,
72        context: &crate::RuntimeContext,
73    ) -> Result<(), ContractValidationError> {
74        let name = name.into();
75        let previous = self.extensions.insert(name.clone(), value);
76        if let Err(error) = self.validate_with_context(context) {
77            match previous {
78                Some(previous) => {
79                    self.extensions.insert(name, previous);
80                }
81                None => {
82                    self.extensions.remove(&name);
83                }
84            }
85            return Err(error);
86        }
87        Ok(())
88    }
89
90    pub fn validate(&self, limits: &Limits) -> Result<(), ContractValidationError> {
91        self.validate_with_context(&crate::RuntimeContext::new(limits.clone()))
92    }
93
94    pub fn validate_with_context(
95        &self,
96        context: &crate::RuntimeContext,
97    ) -> Result<(), ContractValidationError> {
98        let mut budget = context.budget();
99        crate::validate::validate_contract_with_budget(&self.contract, &mut budget)?;
100        self.validate_extensions_with_budget(&mut budget)
101    }
102
103    /// Validate only the extension map, on a budget the caller already owns.
104    ///
105    /// Split out so a bounded parse can validate the Contract and the
106    /// extensions on one budget instead of two independent allowances.
107    pub(crate) fn validate_extensions_with_budget(
108        &self,
109        budget: &mut crate::budget::Budget<'_>,
110    ) -> Result<(), ContractValidationError> {
111        let max_value_bytes = budget.limits().max_value_bytes;
112        let mut bytes = 0usize;
113        for (name, value) in &self.extensions {
114            budget
115                .checkpoint()
116                .map_err(crate::budget::BudgetError::into_contract_error)?;
117            if !valid_extension_name(name) {
118                return Err(ContractValidationError::single(
119                    "invalid_extension_name",
120                    "$.extensions",
121                    format!(
122                        "extension {name:?} must be a reverse-domain name followed by /v<integer>"
123                    ),
124                ));
125            }
126            bytes = bytes
127                .saturating_add(name.len())
128                .saturating_add(serde_json::to_vec(value).map_or(usize::MAX, |value| value.len()));
129        }
130        budget
131            .observe("extension_bytes", max_value_bytes, bytes)
132            .map_err(crate::budget::BudgetError::into_contract_error)?;
133        Ok(())
134    }
135
136    /// Parse, validate, and canonicalize an envelope JSON document under
137    /// caller-supplied limits.
138    pub fn from_json_with_limits(input: &str, limits: &Limits) -> Result<Self, ContractJsonError> {
139        Self::from_json_with_context(input, &crate::RuntimeContext::new(limits.clone()))
140    }
141
142    /// Bounded parse: `max_input_bytes` is enforced before decoding, and the
143    /// nested Contract and the extension map are validated on one budget.
144    pub fn from_json_with_context(
145        input: &str,
146        context: &crate::RuntimeContext,
147    ) -> Result<Self, ContractJsonError> {
148        let mut budget = context.budget();
149        let raw: RawContractEnvelope =
150            crate::budget::decode_bounded(&mut budget, input.len(), || {
151                serde_json::from_str(input)
152            })?;
153        Self::from_raw_with_budget(raw, &mut budget)
154    }
155
156    /// Bounded parse from bytes.
157    pub fn from_slice_with_limits(
158        input: &[u8],
159        limits: &Limits,
160    ) -> Result<Self, ContractJsonError> {
161        Self::from_slice_with_context(input, &crate::RuntimeContext::new(limits.clone()))
162    }
163
164    pub fn from_slice_with_context(
165        input: &[u8],
166        context: &crate::RuntimeContext,
167    ) -> Result<Self, ContractJsonError> {
168        let mut budget = context.budget();
169        let raw: RawContractEnvelope =
170            crate::budget::decode_bounded(&mut budget, input.len(), || {
171                serde_json::from_slice(input)
172            })?;
173        Self::from_raw_with_budget(raw, &mut budget)
174    }
175
176    fn from_raw_with_budget(
177        raw: RawContractEnvelope,
178        budget: &mut crate::budget::Budget<'_>,
179    ) -> Result<Self, ContractJsonError> {
180        let (contract, _) = Contract::from_raw_with_mapping_and_budget(raw.contract, budget)
181            .map_err(ContractJsonError::InvalidContract)?;
182        let envelope = Self {
183            contract,
184            extensions: raw.extensions,
185        };
186        envelope
187            .validate_extensions_with_budget(budget)
188            .map_err(ContractJsonError::InvalidContract)?;
189        Ok(envelope)
190    }
191
192    /// Serialize the envelope under caller-supplied limits.
193    pub fn to_json_pretty_with_limits(
194        &self,
195        limits: &Limits,
196    ) -> Result<String, ContractValidationError> {
197        self.to_json_pretty_with_context(&crate::RuntimeContext::new(limits.clone()))
198    }
199
200    /// Like [`Contract::to_json_pretty_with_context`], this revalidates and
201    /// charges the rendered length against `max_canonicalization_work`.
202    pub fn to_json_pretty_with_context(
203        &self,
204        context: &crate::RuntimeContext,
205    ) -> Result<String, ContractValidationError> {
206        let mut budget = context.budget();
207        crate::validate::validate_contract_with_budget(&self.contract, &mut budget)?;
208        self.validate_extensions_with_budget(&mut budget)?;
209        budget
210            .checkpoint()
211            .map_err(crate::budget::BudgetError::into_contract_error)?;
212        let json = serde_json::to_string_pretty(self).map_err(|error| {
213            ContractValidationError::single(
214                "contract_json_serialization_failed",
215                "$",
216                error.to_string(),
217            )
218        })?;
219        let max_work = budget.limits().max_canonicalization_work;
220        budget
221            .charge("canonicalization_work", max_work, json.len())
222            .map_err(crate::budget::BudgetError::into_contract_error)?;
223        budget
224            .checkpoint()
225            .map_err(crate::budget::BudgetError::into_contract_error)?;
226        Ok(json)
227    }
228}
229
230/// Unvalidated envelope data.
231///
232/// `contract` is a [`RawContract`], not a [`Contract`]: the nested Contract is
233/// validated on the envelope's own budget rather than on an independent
234/// default-limited one. Kept private — [`ContractEnvelope::from_json_with_context`]
235/// is the decode path, and the envelope carries no [`Deserialize`] impl:
236///
237/// ```compile_fail
238/// let _: candid_core::ContractEnvelope = serde_json::from_str("{}").unwrap();
239/// ```
240#[derive(Deserialize)]
241#[serde(deny_unknown_fields)]
242struct RawContractEnvelope {
243    contract: RawContract,
244    #[serde(default)]
245    extensions: BTreeMap<String, Value>,
246}
247
248fn valid_extension_name(name: &str) -> bool {
249    let Some((namespace, version)) = name.rsplit_once("/v") else {
250        return false;
251    };
252    namespace.contains('.')
253        && namespace
254            .split('.')
255            .all(|segment| !segment.is_empty() && segment.bytes().all(is_name_byte))
256        && !version.is_empty()
257        && version.bytes().all(|byte| byte.is_ascii_digit())
258        && !version.starts_with('0')
259}
260
261fn is_name_byte(byte: u8) -> bool {
262    byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'
263}