1use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7use std::fmt;
8
9mod value;
10pub use value::{ScriptValue, ValueLimits};
11
12pub const CONTRACT_VERSION: u32 = 1;
13pub const ARTIFACT_FORMAT: &str = "javascript-esm";
14pub const SCRIPT_CALL_OPCODE: u8 = 0x1d;
16pub const SCRIPT_CALL_OPERANDS: usize = 4;
17pub const MAX_FUNCTIONS: usize = 100;
18pub const MAX_SOURCE_BYTES: usize = 256 * 1024;
19pub const MAX_YAML_BYTES: usize = 256 * 1024;
20pub const MAX_ARTIFACT_BYTES: usize = 4 * 1024 * 1024;
21pub const MAX_CONTRACT_DEPTH: usize = 64;
22pub const MAX_CONTRACT_FIELDS: usize = 65_536;
23pub const MAX_SAFE_VERSION: u64 = 9_007_199_254_740_991;
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct ContractError(pub String);
27
28impl fmt::Display for ContractError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 f.write_str(&self.0)
31 }
32}
33
34impl std::error::Error for ContractError {}
35
36fn invalid(message: impl Into<String>) -> ContractError {
37 ContractError(message.into())
38}
39
40pub fn is_identifier(value: &str, max_bytes: usize) -> bool {
41 let mut bytes = value.bytes();
42 value.len() <= max_bytes
43 && bytes
44 .next()
45 .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
46 && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
47}
48
49pub fn validate_sha256(value: &str) -> Result<(), ContractError> {
50 if value.len() != 64
51 || !value
52 .bytes()
53 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
54 {
55 return Err(invalid(
56 "sha256 must contain exactly 64 lowercase hexadecimal digits",
57 ));
58 }
59 Ok(())
60}
61
62fn validate_owner(value: &str) -> Result<(), ContractError> {
63 if value.is_empty()
64 || value.len() > 256
65 || value.trim() != value
66 || value.chars().any(char::is_control)
67 {
68 return Err(invalid(
69 "resource owner must be nonempty bounded text without controls",
70 ));
71 }
72 Ok(())
73}
74
75fn validate_version(value: u64) -> Result<(), ContractError> {
76 if !(1..=MAX_SAFE_VERSION).contains(&value) {
77 return Err(invalid("resource version must be a positive safe integer"));
78 }
79 Ok(())
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(
85 tag = "kind",
86 rename_all = "camelCase",
87 rename_all_fields = "camelCase",
88 deny_unknown_fields
89)]
90pub enum ScriptRef {
91 Integration {
92 package_id: String,
93 version: u64,
94 function: String,
95 sha256: String,
96 },
97 Automation {
98 automation_id: String,
99 revision: u64,
100 function: String,
101 sha256: String,
102 },
103}
104
105impl ScriptRef {
106 pub fn validate(&self) -> Result<(), ContractError> {
107 let (owner, version, function, sha256) = match self {
108 Self::Integration {
109 package_id,
110 version,
111 function,
112 sha256,
113 } => (package_id, *version, function, sha256),
114 Self::Automation {
115 automation_id,
116 revision,
117 function,
118 sha256,
119 } => (automation_id, *revision, function, sha256),
120 };
121 validate_owner(owner)?;
122 validate_version(version)?;
123 if !is_identifier(function, 128) {
124 return Err(invalid(
125 "function must be an ASCII identifier without a path or extension",
126 ));
127 }
128 validate_sha256(sha256)
129 }
130
131 pub fn sha256(&self) -> &str {
132 match self {
133 Self::Integration { sha256, .. } | Self::Automation { sha256, .. } => sha256,
134 }
135 }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "lowercase")]
140pub enum ScalarType {
141 Bool,
142 Number,
143 String,
144 Timestamp,
145 Duration,
146 Temperature,
147 Image,
148 Object,
149 Value,
150}
151
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct ListType {
155 pub list: Box<FieldType>,
156}
157
158#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(untagged)]
161pub enum FieldType {
162 Scalar(ScalarType),
163 List(ListType),
164}
165
166impl FieldType {
167 pub fn list(element: Self) -> Self {
168 Self::List(ListType {
169 list: Box::new(element),
170 })
171 }
172
173 fn validate(&self, depth: usize) -> Result<(), ContractError> {
174 if depth > MAX_CONTRACT_DEPTH {
175 return Err(invalid("script contract exceeds the nesting limit"));
176 }
177 match self {
178 Self::Scalar(_) => Ok(()),
179 Self::List(list) => list.list.validate(depth + 1),
180 }
181 }
182}
183
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase", deny_unknown_fields)]
186pub struct Field {
187 pub name: String,
188 #[serde(rename = "type")]
189 pub ty: FieldType,
190}
191
192#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase", deny_unknown_fields)]
195pub struct ScriptContract {
196 pub version: u32,
197 pub sdk_version: String,
198 pub input: Vec<Field>,
199 pub output: Vec<Field>,
200}
201
202impl ScriptContract {
203 pub fn validate_input(&self, value: &ScriptValue) -> Result<(), ContractError> {
204 self.validate()?;
205 validate_fields(&self.input, value, NumberRepresentation::CoreInput)
206 }
207
208 pub fn validate_output(&self, value: &ScriptValue) -> Result<(), ContractError> {
209 self.validate()?;
210 validate_fields(&self.output, value, NumberRepresentation::JavaScriptOutput)
211 }
212
213 pub fn validate(&self) -> Result<(), ContractError> {
214 if self.version != CONTRACT_VERSION {
215 return Err(invalid("unsupported script contract version"));
216 }
217 validate_sdk_version(&self.sdk_version)?;
218 for fields in [&self.input, &self.output] {
219 if fields.len() > MAX_CONTRACT_FIELDS {
220 return Err(invalid("script contract has too many fields"));
221 }
222 let mut names = BTreeSet::new();
223 for field in fields {
224 if !is_identifier(&field.name, 255) || !names.insert(&field.name) {
225 return Err(invalid(
226 "script contract field names must be unique identifiers",
227 ));
228 }
229 field.ty.validate(0)?;
230 }
231 }
232 Ok(())
233 }
234}
235
236#[derive(Clone, Copy)]
239enum NumberRepresentation {
240 CoreInput,
241 JavaScriptOutput,
242}
243
244fn validate_fields(
245 fields: &[Field],
246 value: &ScriptValue,
247 numbers: NumberRepresentation,
248) -> Result<(), ContractError> {
249 let ScriptValue::Object(values) = value else {
250 return Err(invalid("script input/output root must be an object"));
251 };
252 if fields.len() != values.len() {
253 return Err(invalid(
254 "script input/output fields must exactly match the contract",
255 ));
256 }
257 for field in fields {
258 let value = values
259 .get(&field.name)
260 .ok_or_else(|| invalid(format!("missing script field: {}", field.name)))?;
261 if !matches_type(&field.ty, value, numbers) {
262 return Err(invalid(format!(
263 "script field does not match its contract: {}",
264 field.name
265 )));
266 }
267 }
268 Ok(())
269}
270
271fn matches_type(ty: &FieldType, value: &ScriptValue, numbers: NumberRepresentation) -> bool {
272 use ScriptValue as V;
273 match ty {
274 FieldType::List(element) => match value {
275 V::List(values) => values
276 .iter()
277 .all(|value| matches_type(&element.list, value, numbers)),
278 _ => false,
279 },
280 FieldType::Scalar(ty) => match ty {
281 ScalarType::Value => true,
282 ScalarType::Bool => matches!(value, V::Bool(_)),
283 ScalarType::Number => match value {
285 V::Number(n) => n.is_finite(),
286 V::I64(n) => {
287 matches!(numbers, NumberRepresentation::CoreInput)
288 && n.unsigned_abs() <= MAX_SAFE_VERSION
289 }
290 _ => false,
291 },
292 ScalarType::String => matches!(value, V::String(_)),
293 ScalarType::Object => matches!(value, V::Object(_)),
294 ScalarType::Image => match value {
295 V::Object(fields) => {
296 matches!(fields.get("uri"), Some(V::String(uri)) if !uri.is_empty())
297 }
298 _ => false,
299 },
300 ScalarType::Timestamp | ScalarType::Duration | ScalarType::Temperature => {
301 let V::Object(fields) = value else {
302 return false;
303 };
304 let (tag, units): (&str, &[&str]) = match ty {
305 ScalarType::Timestamp => ("Timestamp", &[]),
306 ScalarType::Duration => ("Duration", &["ms", "sec", "min", "hour", "day"]),
307 _ => ("Temperature", &["C", "F", "K"]),
308 };
309 if !matches!(fields.get("_type"), Some(V::String(actual)) if actual == tag) {
310 return false;
311 }
312 if *ty == ScalarType::Timestamp {
313 matches!(fields.get("value"), Some(V::I64(_)))
314 || matches!(fields.get("value"), Some(V::U64(n)) if *n <= i64::MAX as u64)
315 || matches!(fields.get("value"), Some(V::Number(n)) if n.is_finite() && n.fract() == 0.0 && n.abs() <= MAX_SAFE_VERSION as f64)
316 } else {
317 matches!(fields.get("unit"), Some(V::String(unit)) if units.contains(&unit.as_str()))
318 && fields.get("value").is_some_and(|v| {
319 matches_type(&FieldType::Scalar(ScalarType::Number), v, numbers)
320 })
321 }
322 }
323 },
324 }
325}
326
327pub fn validate_sdk_version(value: &str) -> Result<(), ContractError> {
328 let parts: Vec<_> = value.split('.').collect();
329 if value.len() > 64
330 || parts.len() != 3
331 || parts.iter().any(|part| {
332 part.is_empty()
333 || (part.len() > 1 && part.starts_with('0'))
334 || !part.bytes().all(|b| b.is_ascii_digit())
335 })
336 {
337 return Err(invalid(
338 "sdkVersion must be an exact stable major.minor.patch version",
339 ));
340 }
341 Ok(())
342}
343
344#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
345pub enum ArtifactFormat {
346 #[serde(rename = "javascript-esm")]
347 JavascriptEsm,
348}
349
350#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase", deny_unknown_fields)]
353pub struct ScriptArtifact {
354 pub format: ArtifactFormat,
355 pub sha256: String,
356 pub size: usize,
357 pub sdk_version: String,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub contract: Option<ScriptContract>,
360}
361
362impl ScriptArtifact {
363 pub fn validate_for(&self, reference: &ScriptRef) -> Result<(), ContractError> {
365 reference.validate()?;
366 validate_sha256(&self.sha256)?;
367 validate_sdk_version(&self.sdk_version)?;
368 if self.sha256 != reference.sha256() || !(1..=MAX_ARTIFACT_BYTES).contains(&self.size) {
369 return Err(invalid("script artifact identity or size mismatch"));
370 }
371 match (&self.contract, reference) {
372 (Some(contract), ScriptRef::Automation { .. }) => {
373 contract.validate()?;
374 if contract.sdk_version != self.sdk_version {
375 return Err(invalid("script contract SDK does not match the artifact"));
376 }
377 }
378 (None, ScriptRef::Integration { .. }) => {}
379 _ => {
380 return Err(invalid(
381 "only Automation artifacts carry a defineScript contract",
382 ))
383 }
384 }
385 Ok(())
386 }
387}
388
389#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(deny_unknown_fields)]
391pub struct ScriptError {
392 pub code: String,
393 pub message: String,
394}
395
396impl ScriptError {
397 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
398 Self {
399 code: code.into(),
400 message: message.into(),
401 }
402 }
403}
404
405impl fmt::Display for ScriptError {
406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407 write!(f, "{}: {}", self.code, self.message)
408 }
409}
410
411impl std::error::Error for ScriptError {}
412
413#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(tag = "status", rename_all = "camelCase", deny_unknown_fields)]
416pub enum ScriptOutcome<T> {
417 Success { data: T },
418 Failure { error: ScriptError },
419}