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    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/// Scalar tags or recursively nested {"list": ...}. Objects remain intentionally dynamic.
159#[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/// Build-generated metadata from defineScript. Every top-level field is required.
193#[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// Core's safe i64 input becomes a JS Number. On output, I64/U64 came from
237// JS BigInt, even when small: accepting it as t.number would hide a type error.
238#[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            // t.number is JS Number. Exact integers needing BigInt require t.value.
284            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/// Metadata and source must be bound by the same verified manifest.
351#[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    /// Checks metadata consistency, not caller authorization or the source digest itself.
364    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/// Internal outcome only. TypeScript functions return data or throw ScriptError.
414#[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}