Skip to main content

lenso_contract_runtime/
lib.rs

1//! Runtime-neutral wire primitives and serde support for generated Lenso contracts.
2
3use std::collections::BTreeMap;
4
5use ::serde::{Serialize, de::DeserializeOwned};
6use base64::{Engine as _, engine::general_purpose::STANDARD};
7
8/// Signed 64-bit integer encoded as a decimal string on the wire.
9pub type Int64 = String;
10/// Unsigned 64-bit integer encoded as a decimal string on the wire.
11pub type Uint64 = String;
12/// RFC 3339 timestamp encoded as a string on the wire.
13pub type Timestamp = String;
14/// ISO 8601 duration encoded as a string on the wire.
15pub type Duration = String;
16/// Distinguishes a missing field from an explicit `null` value.
17pub type OptionalValue<T> = Option<Option<T>>;
18
19/// Shared native bytes encoded as canonical padded Base64 on the wire.
20#[derive(Clone, Debug, Default, Eq, PartialEq)]
21pub struct Bytes(bytes::Bytes);
22
23impl Bytes {
24    /// Wraps an owned or shared byte buffer without copying when possible.
25    pub fn new(value: impl Into<bytes::Bytes>) -> Self {
26        Self(value.into())
27    }
28
29    /// Returns the byte slice.
30    #[must_use]
31    pub fn as_slice(&self) -> &[u8] {
32        &self.0
33    }
34
35    /// Returns the shared byte buffer without copying.
36    #[must_use]
37    pub fn into_shared(self) -> bytes::Bytes {
38        self.0
39    }
40
41    /// Returns an owned vector containing the bytes.
42    #[must_use]
43    pub fn into_vec(self) -> Vec<u8> {
44        self.0.to_vec()
45    }
46}
47
48impl From<Vec<u8>> for Bytes {
49    fn from(value: Vec<u8>) -> Self {
50        Self(value.into())
51    }
52}
53
54impl From<&[u8]> for Bytes {
55    fn from(value: &[u8]) -> Self {
56        Self(bytes::Bytes::copy_from_slice(value))
57    }
58}
59
60impl From<bytes::Bytes> for Bytes {
61    fn from(value: bytes::Bytes) -> Self {
62        Self(value)
63    }
64}
65
66impl From<Bytes> for bytes::Bytes {
67    fn from(value: Bytes) -> Self {
68        value.0
69    }
70}
71
72impl From<Bytes> for Vec<u8> {
73    fn from(value: Bytes) -> Self {
74        value.into_vec()
75    }
76}
77
78impl AsRef<[u8]> for Bytes {
79    fn as_ref(&self) -> &[u8] {
80        self.as_slice()
81    }
82}
83
84impl std::ops::Deref for Bytes {
85    type Target = [u8];
86
87    fn deref(&self) -> &Self::Target {
88        self.as_slice()
89    }
90}
91
92impl Serialize for Bytes {
93    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
94    where
95        S: ::serde::Serializer,
96    {
97        serializer.serialize_str(&STANDARD.encode(self.as_slice()))
98    }
99}
100
101impl<'de> ::serde::Deserialize<'de> for Bytes {
102    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
103    where
104        D: ::serde::Deserializer<'de>,
105    {
106        let encoded = <String as ::serde::Deserialize>::deserialize(deserializer)?;
107        STANDARD
108            .decode(encoded)
109            .map(Self::from)
110            .map_err(|_| ::serde::de::Error::custom("bytes must be canonical padded base64"))
111    }
112}
113
114/// A forward-compatible Capability-defined error unknown to this binding version.
115#[derive(Clone, Debug, PartialEq, Serialize, ::serde::Deserialize)]
116pub struct UnknownDomainError {
117    /// Stable Domain Error code.
118    pub code: String,
119    /// Optional opaque error payload.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub payload: Option<serde_json::Value>,
122    /// Additional fields preserved for forward compatibility.
123    #[serde(default, flatten)]
124    pub extra: BTreeMap<String, serde_json::Value>,
125}
126
127/// Encodes a typed value after enforcing the portable JSON number profile.
128pub fn encode_portable_json<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
129    let value = serde_json::to_value(value)?;
130    validate_portable_json_value(&value).map_err(portable_json_error)?;
131    serde_json::to_string(&value)
132}
133
134/// Decodes a typed value after enforcing the portable JSON number profile.
135pub fn decode_portable_json<T: DeserializeOwned>(wire: &str) -> Result<T, serde_json::Error> {
136    let value: serde_json::Value = serde_json::from_str(wire)?;
137    validate_portable_json_value(&value).map_err(portable_json_error)?;
138    serde_json::from_value(value)
139}
140
141/// Validates recursively that ordinary JSON numbers are portable across runtimes.
142pub fn validate_portable_json_value(value: &serde_json::Value) -> Result<(), String> {
143    match value {
144        serde_json::Value::Number(number) => {
145            let safe = number.as_i64().is_some_and(|value| {
146                (-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&value)
147            }) || number
148                .as_u64()
149                .is_some_and(|value| value <= 9_007_199_254_740_991)
150                || (number.is_f64()
151                    && number.as_f64().is_some_and(|value| {
152                        value.is_finite()
153                            && (value.abs() <= 9_007_199_254_740_991.0 || value.fract() != 0.0)
154                    }));
155            if !safe {
156                return Err("wire JSON contains an unsafe number".to_owned());
157            }
158        }
159        serde_json::Value::Array(values) => {
160            for value in values {
161                validate_portable_json_value(value)?;
162            }
163        }
164        serde_json::Value::Object(values) => {
165            for value in values.values() {
166                validate_portable_json_value(value)?;
167            }
168        }
169        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => {}
170    }
171    Ok(())
172}
173
174fn portable_json_error(detail: String) -> serde_json::Error {
175    serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, detail))
176}
177
178/// Serde helpers used by generated field attributes.
179pub mod serde {
180    /// Deserializes a required field while allowing generated structs to reject omission.
181    pub fn deserialize_required<'de, D, T>(deserializer: D) -> Result<T, D::Error>
182    where
183        D: ::serde::Deserializer<'de>,
184        T: ::serde::Deserialize<'de>,
185    {
186        <T as ::serde::Deserialize>::deserialize(deserializer)
187    }
188
189    /// Preserves the distinction between a missing field and an explicit `null`.
190    #[allow(clippy::option_option)]
191    pub fn deserialize_optional_value<'de, D, T>(
192        deserializer: D,
193    ) -> Result<Option<Option<T>>, D::Error>
194    where
195        D: ::serde::Deserializer<'de>,
196        T: ::serde::Deserialize<'de>,
197    {
198        Ok(Some(<Option<T> as ::serde::Deserialize>::deserialize(
199            deserializer,
200        )?))
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn bytes_preserve_canonical_padded_base64_wire_behavior() {
210        for length in 0..=258 {
211            let value = Bytes::from(
212                (0..length)
213                    .map(|index| u8::try_from(index % 256).unwrap())
214                    .collect::<Vec<_>>(),
215            );
216            let wire = serde_json::to_string(&value).unwrap();
217            assert_eq!(serde_json::from_str::<Bytes>(&wire).unwrap(), value);
218        }
219        for wire in [r#""not base64""#, r#""AQI""#, r#""AQJ=""#] {
220            assert!(serde_json::from_str::<Bytes>(wire).is_err());
221        }
222    }
223
224    #[test]
225    fn shared_bytes_round_trip_without_copying() {
226        let source = bytes::Bytes::from_static(b"shared");
227        let contract = Bytes::from(source.clone());
228        assert_eq!(contract.as_slice().as_ptr(), source.as_ptr());
229        let restored = contract.into_shared();
230        assert_eq!(restored.as_ptr(), source.as_ptr());
231    }
232
233    #[test]
234    fn portable_json_rejects_unsafe_integer_values() {
235        let error = decode_portable_json::<serde_json::Value>("9007199254740992").unwrap_err();
236        assert!(error.to_string().contains("unsafe number"));
237    }
238}