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 IntegrationPreview {
98 package_id: String,
99 snapshot: String,
100 function: String,
101 sha256: String,
102 },
103 Automation {
104 automation_id: String,
105 revision: u64,
106 function: String,
107 sha256: String,
108 },
109}
110
111impl ScriptRef {
112 pub fn validate(&self) -> Result<(), ContractError> {
113 let (owner, version, function, sha256) = match self {
114 Self::IntegrationPreview {
115 package_id,
116 snapshot,
117 function,
118 sha256,
119 } => {
120 validate_sha256(snapshot)?;
121 (package_id, None, function, sha256)
122 }
123 Self::Integration {
124 package_id,
125 version,
126 function,
127 sha256,
128 } => (package_id, Some(*version), function, sha256),
129 Self::Automation {
130 automation_id,
131 revision,
132 function,
133 sha256,
134 } => (automation_id, Some(*revision), function, sha256),
135 };
136 validate_owner(owner)?;
137 if let Some(version) = version {
138 validate_version(version)?;
139 }
140 if !is_identifier(function, 128) {
141 return Err(invalid(
142 "function must be an ASCII identifier without a path or extension",
143 ));
144 }
145 validate_sha256(sha256)
146 }
147
148 pub fn sha256(&self) -> &str {
149 match self {
150 Self::Integration { sha256, .. }
151 | Self::IntegrationPreview { sha256, .. }
152 | Self::Automation { sha256, .. } => sha256,
153 }
154 }
155}
156
157#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum ScalarType {
160 Bool,
161 Number,
162 String,
163 Timestamp,
164 Duration,
165 Temperature,
166 Image,
167 Object,
168 Value,
169}
170
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct ListType {
174 pub list: Box<FieldType>,
175}
176
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(untagged)]
180pub enum FieldType {
181 Scalar(ScalarType),
182 List(ListType),
183}
184
185impl FieldType {
186 pub fn list(element: Self) -> Self {
187 Self::List(ListType {
188 list: Box::new(element),
189 })
190 }
191
192 fn validate(&self, depth: usize) -> Result<(), ContractError> {
193 if depth > MAX_CONTRACT_DEPTH {
194 return Err(invalid("script contract exceeds the nesting limit"));
195 }
196 match self {
197 Self::Scalar(_) => Ok(()),
198 Self::List(list) => list.list.validate(depth + 1),
199 }
200 }
201}
202
203#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct Field {
206 pub name: String,
207 #[serde(rename = "type")]
208 pub ty: FieldType,
209}
210
211#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub struct ScriptContract {
215 pub version: u32,
216 pub sdk_version: String,
217 pub input: Vec<Field>,
218 pub output: Vec<Field>,
219}
220
221impl ScriptContract {
222 pub fn validate_input(&self, value: &ScriptValue) -> Result<(), ContractError> {
223 self.validate()?;
224 validate_fields(&self.input, value, NumberRepresentation::CoreInput)
225 }
226
227 pub fn validate_output(&self, value: &ScriptValue) -> Result<(), ContractError> {
228 self.validate()?;
229 validate_fields(&self.output, value, NumberRepresentation::JavaScriptOutput)
230 }
231
232 pub fn validate(&self) -> Result<(), ContractError> {
233 if self.version != CONTRACT_VERSION {
234 return Err(invalid("unsupported script contract version"));
235 }
236 validate_sdk_version(&self.sdk_version)?;
237 for fields in [&self.input, &self.output] {
238 if fields.len() > MAX_CONTRACT_FIELDS {
239 return Err(invalid("script contract has too many fields"));
240 }
241 let mut names = BTreeSet::new();
242 for field in fields {
243 if !is_identifier(&field.name, 255) || !names.insert(&field.name) {
244 return Err(invalid(
245 "script contract field names must be unique identifiers",
246 ));
247 }
248 field.ty.validate(0)?;
249 }
250 }
251 Ok(())
252 }
253}
254
255#[derive(Clone, Copy)]
258enum NumberRepresentation {
259 CoreInput,
260 JavaScriptOutput,
261}
262
263fn validate_fields(
264 fields: &[Field],
265 value: &ScriptValue,
266 numbers: NumberRepresentation,
267) -> Result<(), ContractError> {
268 let ScriptValue::Object(values) = value else {
269 return Err(invalid("script input/output root must be an object"));
270 };
271 if fields.len() != values.len() {
272 return Err(invalid(
273 "script input/output fields must exactly match the contract",
274 ));
275 }
276 for field in fields {
277 let value = values
278 .get(&field.name)
279 .ok_or_else(|| invalid(format!("missing script field: {}", field.name)))?;
280 if !matches_type(&field.ty, value, numbers) {
281 return Err(invalid(format!(
282 "script field does not match its contract: {}",
283 field.name
284 )));
285 }
286 }
287 Ok(())
288}
289
290fn matches_type(ty: &FieldType, value: &ScriptValue, numbers: NumberRepresentation) -> bool {
291 use ScriptValue as V;
292 match ty {
293 FieldType::List(element) => match value {
294 V::List(values) => values
295 .iter()
296 .all(|value| matches_type(&element.list, value, numbers)),
297 _ => false,
298 },
299 FieldType::Scalar(ty) => match ty {
300 ScalarType::Value => true,
301 ScalarType::Bool => matches!(value, V::Bool(_)),
302 ScalarType::Number => match value {
304 V::Number(n) => n.is_finite(),
305 V::I64(n) => {
306 matches!(numbers, NumberRepresentation::CoreInput)
307 && n.unsigned_abs() <= MAX_SAFE_VERSION
308 }
309 _ => false,
310 },
311 ScalarType::String => matches!(value, V::String(_)),
312 ScalarType::Object => matches!(value, V::Object(_)),
313 ScalarType::Image => match value {
314 V::Object(fields) => {
315 matches!(fields.get("uri"), Some(V::String(uri)) if !uri.is_empty())
316 }
317 _ => false,
318 },
319 ScalarType::Timestamp | ScalarType::Duration | ScalarType::Temperature => {
320 let V::Object(fields) = value else {
321 return false;
322 };
323 let (tag, units): (&str, &[&str]) = match ty {
324 ScalarType::Timestamp => ("Timestamp", &[]),
325 ScalarType::Duration => ("Duration", &["ms", "sec", "min", "hour", "day"]),
326 _ => ("Temperature", &["C", "F", "K"]),
327 };
328 if !matches!(fields.get("_type"), Some(V::String(actual)) if actual == tag) {
329 return false;
330 }
331 if *ty == ScalarType::Timestamp {
332 matches!(fields.get("value"), Some(V::I64(_)))
333 || matches!(fields.get("value"), Some(V::U64(n)) if *n <= i64::MAX as u64)
334 || matches!(fields.get("value"), Some(V::Number(n)) if n.is_finite() && n.fract() == 0.0 && n.abs() <= MAX_SAFE_VERSION as f64)
335 } else {
336 matches!(fields.get("unit"), Some(V::String(unit)) if units.contains(&unit.as_str()))
337 && fields.get("value").is_some_and(|v| {
338 matches_type(&FieldType::Scalar(ScalarType::Number), v, numbers)
339 })
340 }
341 }
342 },
343 }
344}
345
346pub fn validate_sdk_version(value: &str) -> Result<(), ContractError> {
347 let parts: Vec<_> = value.split('.').collect();
348 if value.len() > 64
349 || parts.len() != 3
350 || parts.iter().any(|part| {
351 part.is_empty()
352 || (part.len() > 1 && part.starts_with('0'))
353 || !part.bytes().all(|b| b.is_ascii_digit())
354 })
355 {
356 return Err(invalid(
357 "sdkVersion must be an exact stable major.minor.patch version",
358 ));
359 }
360 Ok(())
361}
362
363#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
364pub enum ArtifactFormat {
365 #[serde(rename = "javascript-esm")]
366 JavascriptEsm,
367}
368
369#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct ScriptArtifact {
373 pub format: ArtifactFormat,
374 pub sha256: String,
375 pub size: usize,
376 pub sdk_version: String,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub contract: Option<ScriptContract>,
379}
380
381impl ScriptArtifact {
382 pub fn verify_source<'a>(&self, bytes: &'a [u8]) -> Result<&'a str, ContractError> {
385 use sha2::{Digest, Sha256};
386 if !(1..=MAX_ARTIFACT_BYTES).contains(&self.size)
387 || bytes.len() != self.size
388 || format!("{:x}", Sha256::digest(bytes)) != self.sha256
389 {
390 return Err(invalid("Script content does not match its manifest"));
391 }
392 let source =
393 std::str::from_utf8(bytes).map_err(|_| invalid("Script source is not UTF-8"))?;
394 if source.contains('\0') {
395 return Err(invalid("Invalid JavaScript source"));
396 }
397 Ok(source)
398 }
399
400 pub fn validate_for(&self, reference: &ScriptRef) -> Result<(), ContractError> {
402 reference.validate()?;
403 validate_sha256(&self.sha256)?;
404 validate_sdk_version(&self.sdk_version)?;
405 if self.sha256 != reference.sha256() || !(1..=MAX_ARTIFACT_BYTES).contains(&self.size) {
406 return Err(invalid("script artifact identity or size mismatch"));
407 }
408 match (&self.contract, reference) {
409 (Some(contract), ScriptRef::Automation { .. }) => {
410 contract.validate()?;
411 if contract.sdk_version != self.sdk_version {
412 return Err(invalid("script contract SDK does not match the artifact"));
413 }
414 }
415 (None, ScriptRef::Integration { .. } | ScriptRef::IntegrationPreview { .. }) => {}
416 _ => {
417 return Err(invalid(
418 "only Automation artifacts carry a defineScript contract",
419 ))
420 }
421 }
422 Ok(())
423 }
424}
425
426#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct ScriptError {
429 pub code: String,
430 pub message: String,
431}
432
433impl ScriptError {
434 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
435 Self {
436 code: code.into(),
437 message: message.into(),
438 }
439 }
440}
441
442impl fmt::Display for ScriptError {
443 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444 write!(f, "{}: {}", self.code, self.message)
445 }
446}
447
448impl std::error::Error for ScriptError {}
449
450#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(tag = "status", rename_all = "camelCase", deny_unknown_fields)]
453pub enum ScriptOutcome<T> {
454 Success { data: T },
455 Failure { error: ScriptError },
456}