Skip to main content

mhome_script_api/
lib.rs

1//! Shared contracts for the TypeScript/JavaScript script migration.
2//!
3//! These models do not authorize an invocation or implement an interpreter.
4//! Deserialize, validate, and then resolve ownership against trusted caller context.
5use 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";
14/// New instruction layout; the former Wasm 0x13 and 0x1c layouts are not aliases.
15pub 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/// Exact immutable resource identity. Tenant and Space are deliberately not caller fields.
83#[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/// Scalar tags or recursively nested {"list": ...}. Objects remain intentionally dynamic.
178#[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/// Build-generated metadata from defineScript. Every top-level field is required.
212#[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// Core's safe i64 input becomes a JS Number. On output, I64/U64 came from
256// JS BigInt, even when small: accepting it as t.number would hide a type error.
257#[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            // t.number is JS Number. Exact integers needing BigInt require t.value.
303            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/// Metadata and source must be bound by the same verified manifest.
370#[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    /// Verifies immutable source content. Callers still validate metadata against
383    /// the authorized reference with `validate_for`, including on cache hits.
384    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    /// Checks metadata consistency, not caller authorization or the source digest itself.
401    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/// Internal outcome only. TypeScript functions return data or throw ScriptError.
451#[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}