diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index f2849a8..d580cc4 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -143,6 +143,11 @@ fn build_engine(limits: Limits, logs: Option>>>) -> Rhai engine.register_static_module("log", build_log_module(logs).into()); } + // Stateless utility modules — regex::/random::/time::/json::/base64::/ + // hex::/url::. Always registered, including in the parse-only validate + // path, so script authors get consistent surface in both phases. + sdk::stdlib::register_stdlib(&mut engine); + engine } diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index bbb478c..cff56be 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -13,6 +13,7 @@ pub mod bridge; pub mod cx; +pub mod stdlib; pub use bridge::{dynamic_to_json, json_to_dynamic}; pub use cx::SdkCallCx; diff --git a/crates/executor-core/src/sdk/stdlib/base64.rs b/crates/executor-core/src/sdk/stdlib/base64.rs new file mode 100644 index 0000000..391553e --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/base64.rs @@ -0,0 +1,48 @@ +//! `base64::` — standard and URL-safe Base64. +//! +//! Two encoders are exposed: standard alphabet with padding (`encode`/ +//! `decode`) and URL-safe alphabet without padding (`encode_url`/ +//! `decode_url`). Each encoder accepts both `String` and `Blob` inputs +//! as separate Rhai overloads; decoders always return `Blob` — the +//! caller knows whether the original bytes were textual. + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module}; + +pub fn register(engine: &mut RhaiEngine) { + let mut module = Module::new(); + + module.set_native_fn("encode", |s: &str| -> Result> { + Ok(STANDARD.encode(s.as_bytes())) + }); + module.set_native_fn("encode", |b: Blob| -> Result> { + Ok(STANDARD.encode(&b)) + }); + module.set_native_fn("decode", |s: &str| -> Result> { + STANDARD + .decode(s) + .map_err(|e| format!("base64::decode: {e}").into()) + }); + + module.set_native_fn( + "encode_url", + |s: &str| -> Result> { + Ok(URL_SAFE_NO_PAD.encode(s.as_bytes())) + }, + ); + module.set_native_fn( + "encode_url", + |b: Blob| -> Result> { Ok(URL_SAFE_NO_PAD.encode(&b)) }, + ); + module.set_native_fn( + "decode_url", + |s: &str| -> Result> { + URL_SAFE_NO_PAD + .decode(s) + .map_err(|e| format!("base64::decode_url: {e}").into()) + }, + ); + + engine.register_static_module("base64", module.into()); +} diff --git a/crates/executor-core/src/sdk/stdlib/hex.rs b/crates/executor-core/src/sdk/stdlib/hex.rs new file mode 100644 index 0000000..4357c8a --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/hex.rs @@ -0,0 +1,21 @@ +//! `hex::` — hexadecimal encode/decode (lowercase output, case- +//! insensitive input). String and Blob inputs are both accepted on +//! encode; decode always returns `Blob`. + +use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module}; + +pub fn register(engine: &mut RhaiEngine) { + let mut module = Module::new(); + + module.set_native_fn("encode", |s: &str| -> Result> { + Ok(hex::encode(s.as_bytes())) + }); + module.set_native_fn("encode", |b: Blob| -> Result> { + Ok(hex::encode(&b)) + }); + module.set_native_fn("decode", |s: &str| -> Result> { + hex::decode(s).map_err(|e| format!("hex::decode: {e}").into()) + }); + + engine.register_static_module("hex", module.into()); +} diff --git a/crates/executor-core/src/sdk/stdlib/json.rs b/crates/executor-core/src/sdk/stdlib/json.rs new file mode 100644 index 0000000..ae557eb --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/json.rs @@ -0,0 +1,43 @@ +//! `json::` — JSON parse and stringify. Reuses the bridge functions in +//! `crate::sdk::bridge` so script-visible JSON has the same shape +//! (numbers, maps, arrays, nulls) as `ctx.request.body` already does. + +use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module}; + +use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic}; + +pub fn register(engine: &mut RhaiEngine) { + let mut module = Module::new(); + register_parse(&mut module); + register_stringify(&mut module); + register_stringify_pretty(&mut module); + engine.register_static_module("json", module.into()); +} + +fn register_parse(module: &mut Module) { + module.set_native_fn("parse", |s: &str| -> Result> { + let value: serde_json::Value = + serde_json::from_str(s).map_err(|e| format!("json::parse: {e}"))?; + Ok(json_to_dynamic(value)) + }); +} + +fn register_stringify(module: &mut Module) { + module.set_native_fn( + "stringify", + |v: Dynamic| -> Result> { + serde_json::to_string(&dynamic_to_json(&v)) + .map_err(|e| format!("json::stringify: {e}").into()) + }, + ); +} + +fn register_stringify_pretty(module: &mut Module) { + module.set_native_fn( + "stringify_pretty", + |v: Dynamic| -> Result> { + serde_json::to_string_pretty(&dynamic_to_json(&v)) + .map_err(|e| format!("json::stringify_pretty: {e}").into()) + }, + ); +} diff --git a/crates/executor-core/src/sdk/stdlib/mod.rs b/crates/executor-core/src/sdk/stdlib/mod.rs new file mode 100644 index 0000000..d0096aa --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/mod.rs @@ -0,0 +1,25 @@ +//! Stateless utility modules registered once at engine build via +//! `Engine::register_static_module`. They have no per-call state, no +//! cross-app sensitivity, and no `SdkCallCx` — distinguishing them +//! from stateful service modules (KV, docs, …) which hook into +//! `sdk::register_all` instead. See [docs/sdk-shape.md](../../../../../docs/sdk-shape.md). + +use rhai::Engine as RhaiEngine; + +pub mod base64; +pub mod hex; +pub mod json; +pub mod random; +pub mod regex; +pub mod time; +pub mod url; + +pub fn register_stdlib(engine: &mut RhaiEngine) { + regex::register(engine); + random::register(engine); + time::register(engine); + json::register(engine); + base64::register(engine); + hex::register(engine); + url::register(engine); +} diff --git a/crates/executor-core/src/sdk/stdlib/random.rs b/crates/executor-core/src/sdk/stdlib/random.rs new file mode 100644 index 0000000..84e14f7 --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/random.rs @@ -0,0 +1,70 @@ +//! `random::` — CSPRNG primitives (`rand::rngs::OsRng`). +//! +//! Only the OS RNG is exposed. No "fast non-crypto" variant — scripts +//! should not pick between secure and insecure entropy. Output sizes +//! are capped to keep a single script call from blowing host memory. + +use rand::distributions::{Alphanumeric, DistString}; +use rand::{rngs::OsRng, Rng, RngCore}; +use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module}; +use uuid::Uuid; + +const MAX_BYTES: i64 = 65_536; +const MAX_STRING: i64 = 4_096; + +pub fn register(engine: &mut RhaiEngine) { + let mut module = Module::new(); + register_int(&mut module); + register_float(&mut module); + register_bytes(&mut module); + register_string(&mut module); + register_uuid(&mut module); + engine.register_static_module("random", module.into()); +} + +fn register_int(module: &mut Module) { + module.set_native_fn( + "int", + |min: i64, max: i64| -> Result> { + if min > max { + return Err(format!("random::int: min ({min}) > max ({max})").into()); + } + Ok(OsRng.gen_range(min..=max)) + }, + ); +} + +fn register_float(module: &mut Module) { + module.set_native_fn("float", || -> Result> { + Ok(OsRng.gen::()) + }); +} + +fn register_bytes(module: &mut Module) { + module.set_native_fn("bytes", |n: i64| -> Result> { + if !(0..=MAX_BYTES).contains(&n) { + return Err(format!("random::bytes: n must be in 0..={MAX_BYTES}, got {n}").into()); + } + // Safe: n is non-negative and bounded by MAX_BYTES, which fits in usize. + let len = usize::try_from(n).expect("n bounded above by MAX_BYTES"); + let mut buf = vec![0u8; len]; + OsRng.fill_bytes(&mut buf); + Ok(buf) + }); +} + +fn register_string(module: &mut Module) { + module.set_native_fn("string", |n: i64| -> Result> { + if !(0..=MAX_STRING).contains(&n) { + return Err(format!("random::string: n must be in 0..={MAX_STRING}, got {n}").into()); + } + let len = usize::try_from(n).expect("n bounded above by MAX_STRING"); + Ok(Alphanumeric.sample_string(&mut OsRng, len)) + }); +} + +fn register_uuid(module: &mut Module) { + module.set_native_fn("uuid", || -> Result> { + Ok(Uuid::new_v4().to_string()) + }); +} diff --git a/crates/executor-core/src/sdk/stdlib/regex.rs b/crates/executor-core/src/sdk/stdlib/regex.rs new file mode 100644 index 0000000..7ba5557 --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/regex.rs @@ -0,0 +1,105 @@ +//! `regex::` — non-backtracking regular expressions (Rust `regex` crate). +//! +//! Patterns compile per call. No cache: premature for v1.1.0, and the +//! `regex` crate's linear-time guarantees keep per-call cost bounded. +//! Catastrophic patterns are rejected at compile time by the crate +//! itself; no extra defense needed. + +use regex::Regex; +use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Module}; + +pub fn register(engine: &mut RhaiEngine) { + let mut module = Module::new(); + register_is_match(&mut module); + register_find(&mut module); + register_find_all(&mut module); + register_replace(&mut module); + register_replace_all(&mut module); + register_split(&mut module); + register_captures(&mut module); + engine.register_static_module("regex", module.into()); +} + +fn compile(pattern: &str) -> Result> { + Regex::new(pattern).map_err(|e| format!("invalid regex: {e}").into()) +} + +fn register_is_match(module: &mut Module) { + module.set_native_fn( + "is_match", + |pattern: &str, text: &str| -> Result> { + Ok(compile(pattern)?.is_match(text)) + }, + ); +} + +fn register_find(module: &mut Module) { + module.set_native_fn( + "find", + |pattern: &str, text: &str| -> Result> { + Ok(compile(pattern)? + .find(text) + .map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string()))) + }, + ); +} + +fn register_find_all(module: &mut Module) { + module.set_native_fn( + "find_all", + |pattern: &str, text: &str| -> Result> { + Ok(compile(pattern)? + .find_iter(text) + .map(|m| Dynamic::from(m.as_str().to_string())) + .collect()) + }, + ); +} + +fn register_replace(module: &mut Module) { + module.set_native_fn( + "replace", + |pattern: &str, text: &str, replacement: &str| -> Result> { + Ok(compile(pattern)?.replace(text, replacement).into_owned()) + }, + ); +} + +fn register_replace_all(module: &mut Module) { + module.set_native_fn( + "replace_all", + |pattern: &str, text: &str, replacement: &str| -> Result> { + Ok(compile(pattern)? + .replace_all(text, replacement) + .into_owned()) + }, + ); +} + +fn register_split(module: &mut Module) { + module.set_native_fn( + "split", + |pattern: &str, text: &str| -> Result> { + Ok(compile(pattern)? + .split(text) + .map(|s| Dynamic::from(s.to_string())) + .collect()) + }, + ); +} + +fn register_captures(module: &mut Module) { + module.set_native_fn( + "captures", + |pattern: &str, text: &str| -> Result> { + let re = compile(pattern)?; + Ok(re.captures(text).map_or(Dynamic::UNIT, |caps| { + let arr: Array = caps + .iter() + .map(|m| m.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string()))) + .collect(); + Dynamic::from(arr) + })) + }, + ); +} diff --git a/crates/executor-core/src/sdk/stdlib/time.rs b/crates/executor-core/src/sdk/stdlib/time.rs new file mode 100644 index 0000000..7c0ff6b --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/time.rs @@ -0,0 +1,68 @@ +//! `time::` — UTC time. The canonical "time value" is milliseconds +//! since the Unix epoch as `i64`. ISO 8601 strings are for parsing and +//! display only. UTC only — no timezone support in v1.1.0 (would pull +//! in chrono-tz, deferred until a real use case demands it). + +use chrono::{DateTime, SecondsFormat, Utc}; +use rhai::{Engine as RhaiEngine, EvalAltResult, Module}; + +pub fn register(engine: &mut RhaiEngine) { + let mut module = Module::new(); + register_now(&mut module); + register_now_ms(&mut module); + register_parse(&mut module); + register_format(&mut module); + register_add_seconds(&mut module); + register_diff_seconds(&mut module); + engine.register_static_module("time", module.into()); +} + +fn register_now(module: &mut Module) { + module.set_native_fn("now", || -> Result> { + Ok(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)) + }); +} + +fn register_now_ms(module: &mut Module) { + module.set_native_fn("now_ms", || -> Result> { + Ok(Utc::now().timestamp_millis()) + }); +} + +fn register_parse(module: &mut Module) { + module.set_native_fn("parse", |iso: &str| -> Result> { + DateTime::parse_from_rfc3339(iso) + .map(|dt| dt.timestamp_millis()) + .map_err(|e| format!("time::parse: invalid ISO 8601 / RFC 3339: {e}").into()) + }); +} + +fn register_format(module: &mut Module) { + module.set_native_fn("format", |ms: i64| -> Result> { + DateTime::::from_timestamp_millis(ms) + .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)) + .ok_or_else(|| format!("time::format: ms ({ms}) out of representable range").into()) + }); +} + +fn register_add_seconds(module: &mut Module) { + module.set_native_fn( + "add_seconds", + |ms: i64, secs: i64| -> Result> { + secs.checked_mul(1000) + .and_then(|delta| ms.checked_add(delta)) + .ok_or_else(|| format!("time::add_seconds: overflow (ms={ms}, secs={secs})").into()) + }, + ); +} + +fn register_diff_seconds(module: &mut Module) { + module.set_native_fn( + "diff_seconds", + |a_ms: i64, b_ms: i64| -> Result> { + b_ms.checked_sub(a_ms) + .map(|d| d / 1000) + .ok_or_else(|| format!("time::diff_seconds: overflow (a={a_ms}, b={b_ms})").into()) + }, + ); +} diff --git a/crates/executor-core/src/sdk/stdlib/url.rs b/crates/executor-core/src/sdk/stdlib/url.rs new file mode 100644 index 0000000..1f82883 --- /dev/null +++ b/crates/executor-core/src/sdk/stdlib/url.rs @@ -0,0 +1,64 @@ +//! `url::` — RFC 3986 percent-encoding. +//! +//! `encode`/`decode` operate on opaque component values; `encode_query` +//! builds an `application/x-www-form-urlencoded`-style query string +//! from a Rhai `Map`. Key ordering is the map's natural order (Rhai's +//! `Map` is a `BTreeMap`, so keys come out alphabetically — fine for +//! query strings, which RFC 3986 leaves unordered). + +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use rhai::{Engine as RhaiEngine, EvalAltResult, Map, Module}; + +/// RFC 3986 unreserved set: `A-Z / a-z / 0-9 / - / _ / . / ~`. +/// Everything outside this set gets percent-encoded. +const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); + +pub fn register(engine: &mut RhaiEngine) { + let mut module = Module::new(); + register_encode(&mut module); + register_decode(&mut module); + register_encode_query(&mut module); + engine.register_static_module("url", module.into()); +} + +fn register_encode(module: &mut Module) { + module.set_native_fn("encode", |s: &str| -> Result> { + Ok(utf8_percent_encode(s, UNRESERVED).to_string()) + }); +} + +fn register_decode(module: &mut Module) { + module.set_native_fn("decode", |s: &str| -> Result> { + percent_decode_str(s) + .decode_utf8() + .map(std::borrow::Cow::into_owned) + .map_err(|e| format!("url::decode: invalid UTF-8: {e}").into()) + }); +} + +fn register_encode_query(module: &mut Module) { + module.set_native_fn( + "encode_query", + |m: Map| -> Result> { + let mut out = String::new(); + for (k, v) in m { + if !out.is_empty() { + out.push('&'); + } + out.push_str(&utf8_percent_encode(&k, UNRESERVED).to_string()); + out.push('='); + // Coerce values via `to_string` rather than throwing on + // non-strings — scripts commonly pass numbers/bools here + // and a forced cast at the call site is friction with + // no upside. + let value = v.to_string(); + out.push_str(&utf8_percent_encode(&value, UNRESERVED).to_string()); + } + Ok(out) + }, + ); +}