diff --git a/.gitignore b/.gitignore index 7b3424299..ca417c0d6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ Cargo.lock storyteller-output plans docs +# Track crate-level source docs (the bare `docs` rule above is for generated output) +!crates/buttplug_server/docs/ .deciduous **/.DS_Store .worktrees diff --git a/crates/buttplug_server/CHANGELOG.md b/crates/buttplug_server/CHANGELOG.md index df208de65..0fedbb5d9 100644 --- a/crates/buttplug_server/CHANGELOG.md +++ b/crates/buttplug_server/CHANGELOG.md @@ -1,3 +1,10 @@ +# 11.1.0 (2026-09-01) + +## Features + +- Add optional `rhai-protocols` script protocol loading from a directory, including the loader, handler, and `ServerDeviceManagerBuilder` option +- Add `ButtplugServerError::ScriptProtocolLoadError(String)`, `ProtocolManager::from_map`, and `ServerDeviceManagerBuilder::script_protocol_directory` + # 11.0.0 (2026-07-28) ## Features diff --git a/crates/buttplug_server/Cargo.toml b/crates/buttplug_server/Cargo.toml index 323a6c217..f5489e838 100644 --- a/crates/buttplug_server/Cargo.toml +++ b/crates/buttplug_server/Cargo.toml @@ -23,6 +23,10 @@ crate-type = ["cdylib", "rlib"] default=["tokio-runtime"] tokio-runtime=["buttplug_core/tokio-runtime"] wasm=["buttplug_core/wasm", "uuid/js", "instant/wasm-bindgen"] +# Adds support for loading device protocols written in rhai scripts from a +# directory on disk. Non-default: keeps rhai out of wasm and downstream builds +# until consumers opt in. +rhai-protocols=["dep:rhai"] [dependencies] buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } @@ -59,6 +63,10 @@ byteorder = "1.5.0" rand = { version = "0.10" } derive_more = { version = "2.1.1", features = ["from"] } evalexpr = { version = "13.1.0", features = ["rand"] } +# Embedded scripting engine for externally distributed device protocols. Only +# compiled in when the rhai-protocols feature is enabled; native-only (the +# script loader is not built for wasm targets). +rhai = { version = "~1.26.0", default-features = false, features = ["std", "sync"], optional = true } [target.wasm32-unknown-unknown.dependencies] getrandom = { version = "0.4.3", features = ["wasm_js"]} diff --git a/crates/buttplug_server/docs/script-protocols.md b/crates/buttplug_server/docs/script-protocols.md new file mode 100644 index 000000000..188b35881 --- /dev/null +++ b/crates/buttplug_server/docs/script-protocols.md @@ -0,0 +1,155 @@ +# Script Protocol API v1 + +## Overview + +Script protocols are device protocols written in [Rhai](https://rhai.rs/). When enabled, the server loads them from a directory at startup. Each `.rhai` file defines one protocol. A file is compiled once and its protocol is registered alongside the built-in Rust protocols. If a script reports the same protocol name as a built-in protocol, the script **overrides** that built-in. If multiple files report the same script protocol name, the first file in deterministic sorted filename order is kept and the duplicate is skipped. + +Loading is fail-soft per file: a script that cannot be read, compiled, or validated is skipped with a warning and loading continues. A missing directory is a no-op. An unreadable path or a path that exists but is not a directory fails server startup. + +## Configuring a script directory + +The option belongs to the device-manager builder, not to `ButtplugServerBuilder`: + +```rust +use std::path::PathBuf; + +let mut device_manager = ServerDeviceManagerBuilder::new(device_configuration_manager); +device_manager.script_protocol_directory(PathBuf::from("scripts/protocols")); +``` + +The `rhai-protocols` cargo feature must be enabled. The directory is scanned when the device manager is finished during server startup. Without that feature, configuring a directory is ignored with a warning. + +## Script API contract + +This document describes API version 1. Every script must define `metadata()`. Rhai functions return their final expression, so the examples below use an object or array as the final expression. + +### Required metadata + +```rhai +fn metadata() { + #{ "protocol": "my-protocol", "api_version": 1 } +} +``` + +`metadata()` must return an object map containing: + +- `protocol`: a non-empty string. +- `api_version`: the integer `1`. + +A missing function, wrong return shape, missing field, empty protocol name, or unsupported API version causes that file to be skipped. + +### Optional initial state + +```rhai +fn init_state() { + #{ "speeds": [0, 0] } +} +``` + +`init_state()` is optional and, when present, must return an object map. It is invoked once when the file is loaded. Each device connection receives a deep copy of that result as `this`; state persists across handler calls for that connection and is not shared with other connections. If the function is absent, `this` starts as an empty map. + +### Optional command handlers + +Handlers receive integer arguments in device units. Values have already been scaled to the feature's configured step count. A missing handler means that command is not implemented: it returns `UnhandledCommand` without panicking, just like a release-mode Rust protocol. + +```text +handle_vibrate(index, speed) +handle_oscillate(index, speed) +handle_rotate(index, speed) // speed is signed +handle_constrict(index, level) +handle_spray(index, level) +handle_led(index, level) +handle_temperature(index, level) // level is signed +handle_position(index, position) +handle_position_duration(index, position, duration_ms) +``` + +### Handler return value + +A handler returns an array of command maps. Each command map has these fields: + +- `endpoint` (required): a string naming an `Endpoint`, using its serde name, such as `"tx"`, `"rx"`, or `"command"`. Unknown endpoint strings are errors. +- `data` (required): a `Blob` or an array of integers. Every byte must be strictly in the inclusive range `0..=255`; values are not silently truncated. +- `write_with_response` (optional): a boolean, defaulting to `false`. +- `command_ids` (optional): an array of UUID strings. If omitted, the command uses the UUID of the feature being handled. This can be used for protocols whose writes have a fixed protocol UUID, such as Je Joue's `d3dd2bf5-b029-4bc1-9466-39f82c2e3258`. + +For example: + +```rhai +[ + #{ + "endpoint": "tx", + "data": [0x01, 0x02], + "write_with_response": true, + "command_ids": ["d3dd2bf5-b029-4bc1-9466-39f82c2e3258"], + }, +] +``` + +Runtime errors, incorrect return or field shapes, invalid UUIDs, and out-of-range array bytes are reported as device-specific errors. Script runtime errors use the stable prefix `Rhai protocol : `, followed by the Rhai error text and source position when available. Script execution never panics or hangs. (Invalid endpoint names are rejected as invalid endpoints.) + +## Language subset and limits + +Scripts use core Rhai only: integers, arithmetic and bit operations, arrays, maps, `Blob`, strings, and control flow. `import` and `eval` are disabled and rejected at parse time. Module resolution is a dummy resolver, so scripts cannot load files. The std package's `sleep` function is replaced with an immediate error — it would block a server thread without consuming any of the operation budget below. `print` and `debug` output is routed into the server log rather than written to stdout. + +The shared engine enforces these limits for each call: + +- Maximum 1,000,000 operations. +- Maximum call depth of 64. +- Maximum 1024 defined functions and 1024 live variables. +- Maximum 4096 entries in an array. +- Maximum 4096 entries in an object map. +- Maximum string length of 65,536 characters. + +Loading is also bounded: a single script file may be at most 1 MiB, and at most 256 script files are loaded from one directory (excess files are skipped with a reason). Exceeding a runtime limit terminates the call with an error. + +`init_state()` templates may only contain integers, floats, bools, chars, strings, Blobs, arrays, and maps; anything else (such as function pointers) is rejected at load time so that each device connection's state copy is always fully independent. + +## Worked example: `maxpro.rhai` + +The following is the shipped `crates/buttplug_server/scripts/protocols/maxpro.rhai` example, verbatim: + +```rhai +// MaxPro 2 protocol script. +// +// Port of crates/buttplug_server/src/device/protocol_impl/maxpro.rs: +// single-motor vibration with a trailing checksum byte (wrapping sum of the +// first nine bytes). + +fn metadata() { + #{ "protocol": "maxpro", "api_version": 1 } +} + +fn handle_vibrate(index, speed) { + let data = [ + 0x55, + 0x04, + 0x07, + 0xff, + 0xff, + 0x3f, + speed & 0xff, + 0x5f, + speed & 0xff, + 0x00, + ]; + let crc = 0; + for b in data { + crc = (crc + b) & 0xff; + } + data[9] = crc; + + [ + #{ + "endpoint": "tx", + "data": data, + }, + ] +} +``` + +The shipped `aneros.rhai` is a stateless example: its `handle_vibrate` creates one `tx` packet per motor. The shipped `jejoue.rhai` is stateful: `init_state()` creates `this.speeds`, and `handle_vibrate` updates that state while assigning every write Je Joue's fixed command UUID. + +## Status + +This is phase 1 of script protocol support. Keepalive, scripted initialization, subscriptions, and battery scripting are not available yet. Scripts coexist with Rust protocols and override them only when a configured script successfully loads with the same protocol name. diff --git a/crates/buttplug_server/scripts/protocols/aneros.rhai b/crates/buttplug_server/scripts/protocols/aneros.rhai new file mode 100644 index 000000000..d77c9033b --- /dev/null +++ b/crates/buttplug_server/scripts/protocols/aneros.rhai @@ -0,0 +1,17 @@ +// Aneros protocol script. +// +// Port of crates/buttplug_server/src/device/protocol_impl/aneros.rs: +// stateless vibration, one packet per motor, command byte 0xF1 + motor index. + +fn metadata() { + #{ "protocol": "aneros", "api_version": 1 } +} + +fn handle_vibrate(index, speed) { + [ + #{ + "endpoint": "tx", + "data": [0xF1 + index, speed & 0xff], + }, + ] +} diff --git a/crates/buttplug_server/scripts/protocols/jejoue.rhai b/crates/buttplug_server/scripts/protocols/jejoue.rhai new file mode 100644 index 000000000..84ba13fff --- /dev/null +++ b/crates/buttplug_server/scripts/protocols/jejoue.rhai @@ -0,0 +1,52 @@ +// Je Joue protocol script. +// +// Port of crates/buttplug_server/src/device/protocol_impl/jejoue.rs: +// stateful two-motor vibration. The pattern byte selects which motors run; +// when motor 0 is stopped the reported speed falls back to motor 1. +// +// All writes carry Je Joue's fixed protocol UUID as their command id, so the +// server attributes the write to the protocol rather than a single feature. + +fn metadata() { + #{ "protocol": "jejoue", "api_version": 1 } +} + +fn init_state() { + #{ "speeds": [0, 0] } +} + +fn handle_vibrate(index, speed) { + this.speeds[index] = speed; + + // Default to both vibes. + let pattern = 1; + + // Use vibe 1 as speed. + let out_speed = this.speeds[0]; + let vibe1_running = out_speed > 0; + let vibe2_running = false; + + // Unless it's zero, then give vibe 2 a chance. + if !vibe1_running { + out_speed = this.speeds[1]; + + // If we're vibing on 2 only, then change the pattern. + if out_speed != 0 { + vibe2_running = true; + pattern = 3; + } + } + + // If we're vibing on 1 only, then change the pattern. + if pattern == 1 && out_speed != 0 && !vibe2_running { + pattern = 2; + } + + [ + #{ + "endpoint": "tx", + "data": [pattern, out_speed & 0xff], + "command_ids": ["d3dd2bf5-b029-4bc1-9466-39f82c2e3258"], + }, + ] +} diff --git a/crates/buttplug_server/scripts/protocols/maxpro.rhai b/crates/buttplug_server/scripts/protocols/maxpro.rhai new file mode 100644 index 000000000..63aa1aa27 --- /dev/null +++ b/crates/buttplug_server/scripts/protocols/maxpro.rhai @@ -0,0 +1,36 @@ +// MaxPro 2 protocol script. +// +// Port of crates/buttplug_server/src/device/protocol_impl/maxpro.rs: +// single-motor vibration with a trailing checksum byte (wrapping sum of the +// first nine bytes). + +fn metadata() { + #{ "protocol": "maxpro", "api_version": 1 } +} + +fn handle_vibrate(index, speed) { + let data = [ + 0x55, + 0x04, + 0x07, + 0xff, + 0xff, + 0x3f, + speed & 0xff, + 0x5f, + speed & 0xff, + 0x00, + ]; + let crc = 0; + for b in data { + crc = (crc + b) & 0xff; + } + data[9] = crc; + + [ + #{ + "endpoint": "tx", + "data": data, + }, + ] +} diff --git a/crates/buttplug_server/src/device/protocol.rs b/crates/buttplug_server/src/device/protocol.rs index e624b5706..bc3cbc8b5 100644 --- a/crates/buttplug_server/src/device/protocol.rs +++ b/crates/buttplug_server/src/device/protocol.rs @@ -554,6 +554,18 @@ impl Default for ProtocolManager { } } +impl ProtocolManager { + /// Builds a protocol manager from a pre-populated protocol map. + /// + /// Used for augmenting the built-in protocol set (e.g. with script + /// protocols); the [`Default`] implementation remains the plain built-in + /// set. The map field stays private — callers hand us a complete map and + /// never manipulate the internals directly. + pub fn from_map(protocol_map: HashMap>) -> Self { + Self { protocol_map } + } +} + impl ProtocolManager { pub fn protocol_specializers( &self, diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index d1a54d2ae..00d538556 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -97,6 +97,8 @@ pub mod raw_protocol; pub mod realov; pub mod sakuraneko; pub mod satisfyer; +#[cfg(all(feature = "rhai-protocols", not(target_arch = "wasm32")))] +pub mod script; pub mod sensee; pub mod sensee_capsule; pub mod sensee_v2; diff --git a/crates/buttplug_server/src/device/protocol_impl/script/engine.rs b/crates/buttplug_server/src/device/protocol_impl/script/engine.rs new file mode 100644 index 000000000..a2a2dc554 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/script/engine.rs @@ -0,0 +1,147 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! The shared, hardened rhai engine used by all script protocols. +//! +//! One [`Engine`] instance is created lazily and shared by every +//! [`crate::device::protocol_impl::script`] handler; rhai engines are +//! expensive to build and safe to share when the `sync` feature is on. +//! +//! Hardening applied here (documented in `docs/script-protocols.md`): +//! +//! - Module resolution is a [`DummyModuleResolver`], so `import` can never +//! load files, and `import`/`eval` are additionally rejected at parse time +//! via [`Engine::disable_symbol`]. +//! - The std package's `sleep` is replaced with an error: it blocks the +//! calling thread without consuming operations, so it would bypass every +//! budget below. +//! - `print`/`debug` output is routed into the server log instead of writing +//! to stdout. +//! - Resource budgets: `max_operations`, `max_call_levels`, `max_functions`, +//! `max_variables`, `max_array_size`, `max_map_size`, `max_string_size`. +//! Scripts that exceed a budget terminate with an error instead of hanging. + +use once_cell::sync::Lazy; +use rhai::{Dynamic, Engine, EvalAltResult, Position, module_resolvers::DummyModuleResolver}; + +/// Maximum number of executed operations per script function call. +const MAX_OPERATIONS: u64 = 1_000_000; +/// Maximum function call nesting depth (rhai's default, made explicit). +const MAX_CALL_LEVELS: usize = 64; +/// Maximum number of functions a script may define. +const MAX_FUNCTIONS: usize = 1024; +/// Maximum number of variables live at once while a script runs. +const MAX_VARIABLES: usize = 1024; +/// Maximum size of arrays created by scripts. +const MAX_ARRAY_SIZE: usize = 4096; +/// Maximum size of object maps created by scripts. +const MAX_MAP_SIZE: usize = 4096; +/// Maximum length of strings created by scripts. +const MAX_STRING_SIZE: usize = 65_536; + +/// Error returned by functions that are replaced because they would bypass +/// the engine's execution budgets or break the sandbox. +fn disabled_function_error(message: &str) -> Box { + EvalAltResult::ErrorRuntime(Dynamic::from(message.to_owned()), Position::NONE).into() +} + +static SCRIPT_ENGINE: Lazy = Lazy::new(|| { + let mut engine = Engine::new(); + // Engine::new() installs a FileModuleResolver on native targets; scripts + // must never be able to import modules from disk, so replace it with a + // resolver that can never resolve anything. + engine.set_module_resolver(DummyModuleResolver::new()); + // No dynamic evaluation and no module imports, enforced at parse time. + engine.disable_symbol("eval"); + engine.disable_symbol("import"); + // The std package's sleep(INT)/sleep(FLOAT) block the calling thread + // without executing operations, so no budget ever fires while they run. + // Replace both overloads with an immediate error. + engine.register_fn( + "sleep", + |_seconds: rhai::INT| -> Result> { + Err(disabled_function_error( + "sleep is disabled in protocol scripts", + )) + }, + ); + engine.register_fn( + "sleep", + |_seconds: rhai::FLOAT| -> Result> { + Err(disabled_function_error( + "sleep is disabled in protocol scripts", + )) + }, + ); + // Keep print/debug inside the server's logging instead of raw stdout. + engine.on_print(|text| info!("script print: {text}")); + engine.on_debug(|text, _source, _position| debug!("script debug: {text}")); + // Resource budgets so broken or malicious scripts error out instead of + // hanging the server. + engine.set_max_operations(MAX_OPERATIONS); + engine.set_max_call_levels(MAX_CALL_LEVELS); + engine.set_max_functions(MAX_FUNCTIONS); + engine.set_max_variables(MAX_VARIABLES); + engine.set_max_array_size(MAX_ARRAY_SIZE); + engine.set_max_map_size(MAX_MAP_SIZE); + engine.set_max_string_size(MAX_STRING_SIZE); + engine +}); + +/// Returns the shared hardened script [`Engine`]. +pub(crate) fn script_engine() -> &'static Engine { + Lazy::force(&SCRIPT_ENGINE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_eval_is_disabled() { + let engine = script_engine(); + assert!(engine.compile("let x = eval(\"1 + 1\");").is_err()); + } + + #[test] + fn test_import_is_disabled() { + let engine = script_engine(); + assert!(engine.compile("import \"foo\" as foo;").is_err()); + } + + #[test] + fn test_infinite_loop_terminates() { + let engine = script_engine(); + let ast = engine + .compile("fn spin() { let x = 0; while true { x += 1; } }") + .unwrap(); + let mut scope = rhai::Scope::new(); + let mut options = rhai::CallFnOptions::new(); + options.eval_ast = false; + let result: Result = + engine.call_fn_with_options(options, &mut scope, &ast, "spin", ()); + assert!(result.is_err()); + } + + #[test] + fn test_sleep_is_disabled() { + let engine = script_engine(); + let ast = engine + .compile("fn nap() { sleep(999999999); }") + .expect("sleep(…) should still parse"); + let mut scope = rhai::Scope::new(); + let mut options = rhai::CallFnOptions::new(); + options.eval_ast = false; + let result: Result = + engine.call_fn_with_options(options, &mut scope, &ast, "nap", ()); + let error = result.expect_err("sleep should error immediately"); + assert!( + error.to_string().contains("sleep is disabled"), + "error: {error}" + ); + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/script/handler.rs b/crates/buttplug_server/src/device/protocol_impl/script/handler.rs new file mode 100644 index 000000000..8b8966190 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/script/handler.rs @@ -0,0 +1,438 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! [`ProtocolHandler`] implementation backed by a compiled rhai protocol +//! script. + +use std::sync::{Arc, Mutex}; + +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::Endpoint; +use rhai::{AST, CallFnOptions, Dynamic, Engine, Map, Scope}; +use std::str::FromStr; +use uuid::Uuid; + +use crate::device::{ + hardware::{HardwareCommand, HardwareWriteCmd}, + protocol::{ + GenericProtocolIdentifier, + ProtocolHandler, + ProtocolIdentifier, + ProtocolIdentifierFactory, + }, +}; + +use super::engine::script_engine; + +/// Stable prefix used for all script protocol error messages so failures can +/// be attributed to the script that caused them. +fn error_prefix(protocol_name: &str) -> String { + format!("Rhai protocol {protocol_name}: ") +} + +/// A [`ProtocolHandler`] whose behavior is defined by a rhai protocol script. +/// +/// Each device connection gets its own handler instance with its own `this` +/// state (a deep clone of the script's load-time-validated `init_state()` +/// template, or an empty map when the script has none). State persists across +/// handler calls for the lifetime of the connection only. +pub struct ScriptedProtocolHandler { + engine: &'static Engine, + ast: Arc, + protocol_name: String, + state: Mutex, +} + +impl ScriptedProtocolHandler { + /// Creates a new handler. `state_template` must be a map (validated at + /// script load time); it is deep-cloned so instances never share state. + pub fn new(protocol_name: &str, ast: Arc, state_template: Dynamic) -> Self { + Self { + engine: script_engine(), + ast, + protocol_name: protocol_name.to_owned(), + state: Mutex::new(state_template.flatten_clone()), + } + } + + fn script_error(&self, message: impl std::fmt::Display) -> ButtplugDeviceError { + ButtplugDeviceError::DeviceSpecificError(format!( + "{}{}", + error_prefix(&self.protocol_name), + message + )) + } + + fn has_script_fn(&self, fn_name: &str) -> bool { + self.ast.iter_functions().any(|f| f.name == fn_name) + } + + /// Calls a script function with the handler's state bound to `this`. + fn call_script_fn( + &self, + fn_name: &str, + args: Vec, + ) -> Result { + let mut state = self + .state + .lock() + .map_err(|_| self.script_error("internal state mutex poisoned by a previous script panic"))?; + let mut options = CallFnOptions::new().bind_this_ptr(&mut state); + // Never evaluate the AST body: only the named function may run. + options.eval_ast = false; + let mut scope = Scope::new(); + self + .engine + .call_fn_with_options(options, &mut scope, &self.ast, fn_name, args) + .map_err(|e| self.script_error(e)) + } + + /// Converts a script handler's return value (an array of command maps) into + /// hardware commands, validating every field. + fn convert_commands( + &self, + result: Dynamic, + feature_id: Uuid, + ) -> Result, ButtplugDeviceError> { + let array = result + .into_array() + .map_err(|_| self.script_error("handler must return an array of command maps"))?; + + let mut commands = Vec::with_capacity(array.len()); + for (command_index, entry) in array.into_iter().enumerate() { + let map: Map = entry.flatten_clone().try_cast().ok_or_else(|| { + self.script_error(format!("command {command_index} is not an object map")) + })?; + + // endpoint (required): string matching an Endpoint name. + let endpoint_value = map + .get("endpoint") + .ok_or_else(|| self.script_error(format!("command {command_index} is missing endpoint")))?; + let endpoint_str = endpoint_value + .as_immutable_string_ref() + .map_err(|_| { + self.script_error(format!( + "command {command_index} field endpoint must be a string" + )) + })? + .to_string(); + let endpoint = Endpoint::from_str(&endpoint_str) + .map_err(|_| ButtplugDeviceError::InvalidEndpoint(endpoint_str.clone()))?; + + // data (required): a Blob or an array of ints in 0..=255. + let data_value = map + .get("data") + .ok_or_else(|| self.script_error(format!("command {command_index} is missing data")))?; + let data = if let Ok(blob) = data_value.as_blob_ref() { + blob.to_vec() + } else if let Ok(array) = data_value.as_array_ref() { + let mut bytes = Vec::with_capacity(array.len()); + for (byte_index, byte) in array.iter().enumerate() { + let int_value = byte.as_int().map_err(|_| { + self.script_error(format!( + "command {command_index} data[{byte_index}] must be an integer" + )) + })?; + if !(0..=255).contains(&int_value) { + return Err(self.script_error(format!( + "command {command_index} data[{byte_index}] value {int_value} is out of range 0..=255" + ))); + } + bytes.push(int_value as u8); + } + bytes + } else { + return Err(self.script_error(format!( + "command {command_index} field data must be a Blob or an array of integers" + ))); + }; + + // write_with_response (optional, default false): must be a bool. + let write_with_response = match map.get("write_with_response") { + None => false, + Some(value) => value.as_bool().map_err(|_| { + self.script_error(format!( + "command {command_index} field write_with_response must be a bool" + )) + })?, + }; + + // command_ids (optional, defaults to the handled feature's id): array of + // UUID strings. + let command_ids = match map.get("command_ids") { + None => vec![feature_id], + Some(value) => { + let id_array = value.as_array_ref().map_err(|_| { + self.script_error(format!( + "command {command_index} field command_ids must be an array of UUID strings" + )) + })?; + let mut ids = Vec::with_capacity(id_array.len()); + for (id_index, id_value) in id_array.iter().enumerate() { + let id_str = id_value + .as_immutable_string_ref() + .map_err(|_| { + self.script_error(format!( + "command {command_index} command_ids[{id_index}] must be a string" + )) + })? + .as_str() + .to_owned(); + let id = Uuid::parse_str(&id_str).map_err(|_| { + self.script_error(format!( + "command {command_index} command_ids[{id_index}] is not a valid UUID: {id_str}" + )) + })?; + ids.push(id); + } + ids + } + }; + + commands + .push(HardwareWriteCmd::new(&command_ids, endpoint, data, write_with_response).into()); + } + Ok(commands) + } + + /// Shared body for the two-int-arg handlers. + fn handle_two_args( + &self, + script_fn: &str, + unimplemented_msg: &str, + feature_index: u32, + feature_id: Uuid, + value: i64, + ) -> Result, ButtplugDeviceError> { + if !self.has_script_fn(script_fn) { + return Err(ButtplugDeviceError::UnhandledCommand( + unimplemented_msg.to_owned(), + )); + } + let result = self.call_script_fn( + script_fn, + vec![ + Dynamic::from_int(feature_index as i64), + Dynamic::from_int(value), + ], + )?; + self.convert_commands(result, feature_id) + } + + fn handle_three_args( + &self, + script_fn: &str, + unimplemented_msg: &str, + feature_index: u32, + feature_id: Uuid, + value: i64, + duration: u32, + ) -> Result, ButtplugDeviceError> { + if !self.has_script_fn(script_fn) { + return Err(ButtplugDeviceError::UnhandledCommand( + unimplemented_msg.to_owned(), + )); + } + let result = self.call_script_fn( + script_fn, + vec![ + Dynamic::from_int(feature_index as i64), + Dynamic::from_int(value), + Dynamic::from_int(duration as i64), + ], + )?; + self.convert_commands(result, feature_id) + } +} + +impl ProtocolHandler for ScriptedProtocolHandler { + fn handle_output_vibrate_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_vibrate", + "Command not implemented for this protocol: OutputCmd (Vibrate Actuator)", + feature_index, + feature_id, + speed as i64, + ) + } + + fn handle_output_rotate_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + speed: i32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_rotate", + "Command not implemented for this protocol: OutputCmd (Rotate Actuator)", + feature_index, + feature_id, + speed as i64, + ) + } + + fn handle_output_oscillate_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_oscillate", + "Command not implemented for this protocol: OutputCmd (Oscillate Actuator)", + feature_index, + feature_id, + speed as i64, + ) + } + + fn handle_output_spray_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + level: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_spray", + "Command not implemented for this protocol: OutputCmd (Spray Actuator)", + feature_index, + feature_id, + level as i64, + ) + } + + fn handle_output_constrict_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + level: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_constrict", + "Command not implemented for this protocol: OutputCmd (Constrict Actuator)", + feature_index, + feature_id, + level as i64, + ) + } + + fn handle_output_temperature_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + level: i32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_temperature", + "Command not implemented for this protocol: OutputCmd (Temperature Actuator)", + feature_index, + feature_id, + level as i64, + ) + } + + fn handle_output_led_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + level: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_led", + "Command not implemented for this protocol: OutputCmd (Led Actuator)", + feature_index, + feature_id, + level as i64, + ) + } + + fn handle_output_position_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + position: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_two_args( + "handle_position", + "Command not implemented for this protocol: OutputCmd (Position Actuator)", + feature_index, + feature_id, + position as i64, + ) + } + + fn handle_hw_position_with_duration_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + position: u32, + duration: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_three_args( + "handle_position_duration", + "Command not implemented for this protocol: OutputCmd (Position w/ Duration Actuator)", + feature_index, + feature_id, + position as i64, + duration, + ) + } +} + +/// Factory creating a fresh [`ScriptedProtocolHandler`] (with fresh state) per +/// device connection. One factory exists per loaded script. +pub struct ScriptedProtocolFactory { + protocol_name: String, + ast: Arc, + state_template: Dynamic, +} + +impl std::fmt::Debug for ScriptedProtocolFactory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // ASTs are large; report just the identity. + f.debug_struct("ScriptedProtocolFactory") + .field("protocol_name", &self.protocol_name) + .finish() + } +} + +impl ScriptedProtocolFactory { + /// `state_template` must be a map; validated at script load time. + pub fn new(protocol_name: &str, ast: Arc, state_template: Dynamic) -> Self { + Self { + protocol_name: protocol_name.to_owned(), + ast, + state_template, + } + } + + pub(crate) fn handler(&self) -> Arc { + Arc::new(ScriptedProtocolHandler::new( + &self.protocol_name, + self.ast.clone(), + self.state_template.flatten_clone(), + )) + } +} + +impl ProtocolIdentifierFactory for ScriptedProtocolFactory { + fn identifier(&self) -> &str { + &self.protocol_name + } + + fn create(&self) -> Box { + Box::new(GenericProtocolIdentifier::new( + self.handler(), + &self.protocol_name, + )) + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/script/loader.rs b/crates/buttplug_server/src/device/protocol_impl/script/loader.rs new file mode 100644 index 000000000..5ff82d45c --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/script/loader.rs @@ -0,0 +1,311 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Loader for rhai protocol scripts: scans a directory, compiles and +//! validates each `*.rhai` file, and reports per-file outcomes. +//! +//! Failure policy: a missing directory is not an error (nothing is loaded); +//! an unreadable or non-directory path is an error; any per-file failure is +//! fail-soft — the file is skipped with a structured reason and loading +//! continues. + +use std::path::{Path, PathBuf}; + +use rhai::{AST, CallFnOptions, Dynamic, Scope}; + +#[cfg(test)] +use super::handler::ScriptedProtocolHandler; +use super::{engine::script_engine, handler::ScriptedProtocolFactory}; + +/// A successfully loaded script protocol. +#[derive(Debug, Clone)] +pub struct LoadedProtocol { + /// Protocol name reported by the script's `metadata()`. + pub name: String, + /// File the protocol was loaded from. + pub source_path: PathBuf, + /// Factory producing per-connection handlers for this protocol. + pub factory: std::sync::Arc, +} + +impl LoadedProtocol { + /// Test-only: build a handler instance for this protocol (fresh state). + #[cfg(test)] + pub(crate) fn handler_for_test(&self) -> std::sync::Arc { + self.factory.handler() + } +} + +/// A script file that was skipped, with the reason. +#[derive(Debug, Clone)] +pub struct SkippedScript { + pub source_path: PathBuf, + pub reason: String, +} + +/// Structured outcome of loading a script protocol directory. +#[derive(Debug, Default, Clone)] +pub struct ScriptLoadReport { + pub loaded: Vec, + pub skipped: Vec, +} + +/// The script protocol API version this build implements. +const SUPPORTED_API_VERSION: i64 = 1; + +/// Maximum number of script files loaded from a single directory; excess +/// files (in sorted order) are skipped with a reason. +const MAX_SCRIPT_FILES: usize = 256; + +/// Maximum size of a single script file, in bytes. +const MAX_SCRIPT_FILE_SIZE: u64 = 1024 * 1024; + +/// Loads all `*.rhai` protocol scripts from `directory`. +/// +/// Returns `Err` only when the directory itself cannot be read (missing +/// directories are treated as "nothing to load"). Individual files that fail +/// to compile or violate the script contract are skipped and reported. +pub fn load_script_protocols(directory: &Path) -> Result { + let dir_metadata = match std::fs::metadata(directory) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Missing directory: nothing to load, not an error. + return Ok(ScriptLoadReport::default()); + } + Err(e) => { + return Err(format!( + "cannot access script protocol directory {}: {e}", + directory.display() + )); + } + }; + if !dir_metadata.is_dir() { + return Err(format!( + "script protocol path {} is not a directory", + directory.display() + )); + } + + // Sort the file list so duplicate-name resolution ("first file wins") is + // deterministic across platforms. Entry-level inspection failures are + // recorded as skips rather than swallowed, so no file can silently + // disappear from the report. + let mut script_files: Vec = vec![]; + let mut entry_skips: Vec = vec![]; + match std::fs::read_dir(directory) { + Ok(entries) => { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + entry_skips.push(SkippedScript { + source_path: directory.to_owned(), + reason: format!("error while reading directory entry: {e}"), + }); + continue; + } + }; + let path = entry.path(); + match entry.metadata() { + Ok(metadata) => { + if metadata.is_file() + && path + .extension() + .is_some_and(|extension| extension == "rhai") + { + script_files.push(path); + } + } + Err(e) => { + entry_skips.push(SkippedScript { + source_path: path, + reason: format!("cannot inspect directory entry: {e}"), + }); + } + } + } + } + Err(e) => { + return Err(format!( + "cannot read script protocol directory {}: {e}", + directory.display() + )); + } + } + script_files.sort(); + // Cap the number of loaded files so a runaway directory cannot push + // startup cost without bound; the excess (in sorted order) is skipped. + if script_files.len() > MAX_SCRIPT_FILES { + for path in script_files.split_off(MAX_SCRIPT_FILES) { + entry_skips.push(SkippedScript { + source_path: path, + reason: format!( + "script file count exceeds the maximum of {MAX_SCRIPT_FILES}; excess file skipped" + ), + }); + } + } + + let engine = script_engine(); + let mut report = ScriptLoadReport::default(); + report.skipped.extend(entry_skips); + for script_file in script_files { + match load_script_file(engine, &script_file) { + Ok(loaded) => { + if let Some(existing) = report.loaded.iter().find(|l| l.name == loaded.name) { + report.skipped.push(SkippedScript { + source_path: script_file, + reason: format!( + "duplicate protocol name {:?} (already loaded from {})", + loaded.name, + existing.source_path.display() + ), + }); + } else { + report.loaded.push(loaded); + } + } + Err(reason) => { + report.skipped.push(SkippedScript { + source_path: script_file, + reason, + }); + } + } + } + Ok(report) +} + +/// Compiles and validates a single script file. +fn load_script_file(engine: &rhai::Engine, path: &Path) -> Result { + let file_metadata = + std::fs::metadata(path).map_err(|e| format!("cannot read file metadata: {e}"))?; + if file_metadata.len() > MAX_SCRIPT_FILE_SIZE { + return Err(format!( + "script file is larger than the maximum of {MAX_SCRIPT_FILE_SIZE} bytes" + )); + } + let source = std::fs::read_to_string(path).map_err(|e| format!("cannot read file: {e}"))?; + + let ast: AST = engine + .compile(&source) + .map_err(|e| format!("parse error: {e}"))?; + + let has_fn = |name: &str| ast.iter_functions().any(|f| f.name == name); + + // metadata() is required. + if !has_fn("metadata") { + return Err("script has no metadata() function".to_owned()); + } + let metadata = call_script_function(engine, &ast, "metadata", vec![]) + .map_err(|e| format!("metadata() failed: {e}"))?; + + let metadata_map = metadata + .flatten_clone() + .try_cast::() + .ok_or_else(|| "metadata() must return an object map".to_owned())?; + + let protocol_name = metadata_map + .get("protocol") + .and_then(|value| value.as_immutable_string_ref().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| "metadata() is missing string field \"protocol\"".to_owned())?; + if protocol_name.is_empty() { + return Err("metadata() field \"protocol\" must not be empty".to_owned()); + } + + let api_version = metadata_map + .get("api_version") + .and_then(|value| value.as_int().ok()) + .ok_or_else(|| "metadata() is missing integer field \"api_version\"".to_owned())?; + if api_version != SUPPORTED_API_VERSION { + return Err(format!( + "metadata() api_version {api_version} is not supported (this build supports {SUPPORTED_API_VERSION})" + )); + } + + // init_state() is optional; when present it must return a map, and it runs + // once here under the same operation limits as handlers. The template is + // also validated to contain only value types that clone deeply, so the + // per-connection copies handed to handlers can never alias each other + // through shared cells or captured closure environments. + let state_template = if has_fn("init_state") { + let state = call_script_function(engine, &ast, "init_state", vec![]) + .map_err(|e| format!("init_state() failed: {e}"))?; + if !state.is_map() { + return Err("init_state() must return an object map".to_owned()); + } + validate_state_value(&state, "state")?; + state + } else { + Dynamic::from(rhai::Map::new()) + }; + + Ok(LoadedProtocol { + factory: std::sync::Arc::new(ScriptedProtocolFactory::new( + &protocol_name, + std::sync::Arc::new(ast), + state_template, + )), + name: protocol_name, + source_path: path.to_owned(), + }) +} + +/// Calls a script function with the engine's limits in force, without +/// evaluating the AST body. +fn call_script_function( + engine: &rhai::Engine, + ast: &AST, + fn_name: &str, + args: Vec, +) -> Result { + let mut scope = Scope::new(); + let mut options = CallFnOptions::new(); + options.eval_ast = false; + engine + .call_fn_with_options::(options, &mut scope, ast, fn_name, args) + .map_err(|e| e.to_string()) +} + +/// Recursively validates that a state template only contains value types +/// that clone deeply (integers, floats, bools, chars, strings, Blobs, arrays, +/// and maps thereof). +/// +/// Anything else — function pointers, shared cells, timestamps, or any other +/// exotic value — is rejected at load time. This is what makes the +/// per-connection deep-copy guarantee sound: once a template contains only +/// these types, cloning it can never leave two connections aliasing the same +/// underlying value. +fn validate_state_value(value: &Dynamic, path: &str) -> Result<(), String> { + if value.is_unit() + || value.is_bool() + || value.is_int() + || value.is_float() + || value.is_char() + || value.is_string() + || value.is_blob() + { + return Ok(()); + } + if let Ok(array) = value.as_array_ref() { + for (index, element) in array.iter().enumerate() { + validate_state_value(element, &format!("{path}[{index}]"))?; + } + return Ok(()); + } + if let Ok(map) = value.as_map_ref() { + for (key, element) in map.iter() { + validate_state_value(element, &format!("{path}.{key}"))?; + } + return Ok(()); + } + Err(format!( + "init_state() contains an unsupported value of type {} at {path} (allowed: integers, floats, bools, chars, strings, Blobs, arrays, and maps)", + value.type_name() + )) +} diff --git a/crates/buttplug_server/src/device/protocol_impl/script/mod.rs b/crates/buttplug_server/src/device/protocol_impl/script/mod.rs new file mode 100644 index 000000000..41df5d8d7 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/script/mod.rs @@ -0,0 +1,91 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Rhai-based script protocol support. +//! +//! Scripts (one protocol per `*.rhai` file) are loaded from a directory on +//! disk, compiled once, and registered in the protocol map alongside the +//! built-in Rust protocols. A script whose protocol name matches a built-in +//! replaces it; a script with a new name registers a new protocol. +//! +//! The script API contract is documented in `docs/script-protocols.md`; the +//! pieces implemented here are: +//! +//! - [`engine`]: the shared, hardened rhai engine. +//! - [`loader`]: directory scan + per-file validation with a structured, +//! fail-soft report. +//! - [`handler`]: the [`crate::device::protocol::ProtocolHandler`] +//! implementation backed by script functions, with per-connection `this` +//! state. + +mod engine; +mod handler; +mod loader; +#[cfg(test)] +mod tests; + +pub use handler::{ScriptedProtocolFactory, ScriptedProtocolHandler}; +pub use loader::{LoadedProtocol, ScriptLoadReport, SkippedScript, load_script_protocols}; + +use std::{collections::HashMap, path::Path, sync::Arc}; + +use crate::device::protocol::{ProtocolIdentifierFactory, ProtocolManager}; + +/// Builds a [`ProtocolManager`] including any script protocols from +/// `directory` (when `Some`). +/// +/// - `None` → the default (built-in only) protocol manager. +/// - `Some(dir)` where `dir` does not exist → default protocol manager +/// (info-logged). +/// - `Some(dir)` which exists but cannot be read, or is not a directory → +/// `Err` (the caller surfaces this as a startup error). +/// +/// Script protocols override same-name built-ins (info-logged at the point of +/// replacement); per-file script failures are warn-logged and skipped. +/// Logging happens here, at the single call boundary. +pub fn build_script_protocol_manager(directory: Option<&Path>) -> Result { + let Some(directory) = directory else { + return Ok(ProtocolManager::default()); + }; + + let report = load_script_protocols(directory)?; + if report.loaded.is_empty() && report.skipped.is_empty() { + info!( + "No script protocol files found in {}; using built-in protocols only", + directory.display() + ); + } + for skipped in &report.skipped { + warn!( + "Skipping script protocol file {}: {}", + skipped.source_path.display(), + skipped.reason + ); + } + for loaded in &report.loaded { + info!( + "Loaded script protocol {} from {}", + loaded.name, + loaded.source_path.display() + ); + } + + let mut protocol_map: HashMap> = + crate::device::protocol_impl::get_default_protocol_map(); + for loaded in report.loaded { + if protocol_map + .insert(loaded.name.clone(), loaded.factory) + .is_some() + { + info!( + "Script protocol {} overrides built-in protocol of the same name", + loaded.name + ); + } + } + Ok(ProtocolManager::from_map(protocol_map)) +} diff --git a/crates/buttplug_server/src/device/protocol_impl/script/tests.rs b/crates/buttplug_server/src/device/protocol_impl/script/tests.rs new file mode 100644 index 000000000..1f9be4ed7 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/script/tests.rs @@ -0,0 +1,985 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Unit tests for the rhai script protocol subsystem. +//! +//! Test scripts live in this directory as `tests/*.rhai` fixtures; loader +//! failure fixtures are written to temp dirs at runtime via +//! [`write_script_dir`]. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::{ + BluetoothLESpecifier, + DeviceConfigurationManager, + Endpoint, + ProtocolCommunicationSpecifier, + load_protocol_configs, +}; +use uuid::Uuid; + +use crate::device::{ + hardware::{Hardware, HardwareCommand}, + protocol::ProtocolHandler, +}; + +use super::{ScriptedProtocolHandler, build_script_protocol_manager, load_script_protocols}; + +/// Path of the shipped script protocol assets. +fn shipped_scripts_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("scripts") + .join("protocols") +} + +/// Writes a set of `(file_name, contents)` scripts into a fresh temp +/// directory and returns its path. +fn write_script_dir(scripts: &[(&str, &str)]) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "buttplug-script-test-{}-{}", + std::process::id(), + Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).expect("should create script test dir"); + for (name, contents) in scripts { + std::fs::write(dir.join(name), contents).expect("should write script fixture"); + } + dir +} + +/// Compiles a script from source into a handler for a protocol name. +fn handler_from_source(source: &str) -> ScriptedProtocolHandler { + let engine = super::engine::script_engine(); + let ast = Arc::new(engine.compile(source).expect("test script should compile")); + ScriptedProtocolHandler::new("testproto", ast, rhai::Dynamic::from(rhai::Map::new())) +} + +/// AC.1: scripts register; same-name scripts override built-ins; new-name +/// scripts add new protocols. Drives the full specializer → identify → +/// initialize → command path. +#[tokio::test] +async fn script_loader_registers_and_overrides() { + // A script that claims the built-in "aneros" name but returns distinctive + // bytes, plus a script with a novel protocol name. + let aneros_override = r#" +fn metadata() { + #{ "protocol": "aneros", "api_version": 1 } +} +fn handle_vibrate(index, speed) { + [ #{ "endpoint": "tx", "data": [0xAA, 0xBB] } ] +} +"#; + let novel = r#" +fn metadata() { + #{ "protocol": "scriptnovel", "api_version": 1 } +} +fn handle_vibrate(index, speed) { + [ #{ "endpoint": "tx", "data": [0x12, 0x34, speed & 0xff] } ] +} +"#; + let dir = write_script_dir(&[ + ("a_aneros_override.rhai", aneros_override), + ("b_novel.rhai", novel), + ]); + + let protocol_manager = build_script_protocol_manager(Some(&dir)).unwrap(); + + // Device config with communication specifiers for both protocols. The + // aneros entry mirrors the built-in config (names "Massage Demo"); the + // novel protocol gets its own specifier. A custom base config replaces the + // internal one entirely, so both must be present. + let novel_config = serde_json::json!({ + "version": { "major": 5, "minor": 9999 }, + "protocols": { + "aneros": { + "communication": [{ + "btle": { + "names": ["Massage Demo"], + "services": { + "0000ff00-0000-1000-8000-00805f9b34fb": { + "tx": "0000ff01-0000-1000-8000-00805f9b34fb" + } + } + } + }], + "defaults": { + "name": "Aneros Vivi", + "id": "f023f0f4-6629-469e-84c4-171ed4939f3d", + "features": [ + { + "index": 0, + "id": "a980bc1a-5554-4293-a75f-6d17bf25ebee", + "output": { "vibrate": { "value": [0, 127] } } + }, + { + "index": 1, + "id": "811d7d6e-6a75-4925-943a-a06042223e3a", + "output": { "vibrate": { "value": [0, 127] } } + } + ] + } + }, + "scriptnovel": { + "communication": [{ + "btle": { + "names": ["Script Novel Device"], + "services": { + "0000ff00-0000-1000-8000-00805f9b34fb": { + "tx": "0000ff01-0000-1000-8000-00805f9b34fb" + } + } + } + }], + "defaults": { + "name": "Script Novel Device", + "id": "e5f68425-83a5-4b0e-a45f-9dcb27e0a111", + "features": [{ + "index": 0, + "id": "1f32950f-97e5-4f6f-b20f-56c3ac6b2222", + "output": { "vibrate": { "value": [0, 100] } } + }] + } + } + } + }); + let dcm: DeviceConfigurationManager = + load_protocol_configs(&Some(novel_config.to_string()), &None, true) + .unwrap() + .finish() + .unwrap(); + + async fn drive_protocol( + protocol_manager: &crate::device::protocol::ProtocolManager, + dcm: &DeviceConfigurationManager, + protocol: &str, + device_name: &str, + ) -> Vec { + let specifier = ProtocolCommunicationSpecifier::BluetoothLE( + BluetoothLESpecifier::new_from_device(device_name, &HashMap::new(), &[]), + ); + let specializers = protocol_manager.protocol_specializers( + &specifier, + dcm.base_communication_specifiers(), + dcm.user_communication_specifiers(), + ); + assert!( + !specializers.is_empty(), + "expected a specializer for {protocol}" + ); + let mut identifier = specializers.into_iter().next().unwrap().identify(); + let hardware = Arc::new(Hardware::new( + device_name, + "address", + &[Endpoint::Tx], + &None, + false, + Box::new(crate::device::hardware::simulated::SimulatedHardwareInternal::new("address")), + )); + let (device_identifier, mut initializer) = identifier + .identify(hardware.clone(), specifier) + .await + .unwrap(); + assert_eq!(device_identifier.protocol(), protocol); + let definition = dcm.device_definition(&device_identifier).unwrap(); + let handler = initializer.initialize(hardware, &definition).await.unwrap(); + handler + .handle_output_vibrate_cmd(0, Uuid::new_v4(), 50) + .unwrap() + } + + // Overridden built-in: distinctive scripted bytes, not the Rust aneros + // output ([0xF1, speed]). + let aneros_commands = drive_protocol(&protocol_manager, &dcm, "aneros", "Massage Demo").await; + let HardwareCommand::Write(aneros_write) = &aneros_commands[0] else { + panic!("expected a write command"); + }; + assert_eq!(aneros_write.data(), &vec![0xAA, 0xBB]); + + // Novel protocol registers and functions end to end. + let novel_commands = drive_protocol( + &protocol_manager, + &dcm, + "scriptnovel", + "Script Novel Device", + ) + .await; + let HardwareCommand::Write(write) = &novel_commands[0] else { + panic!("expected a write command"); + }; + assert_eq!(write.data(), &vec![0x12, 0x34, 50]); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// AC.2(b): the shipped scripts/protocols directory loads all three +/// protocols with nothing skipped. +#[test] +fn script_assets_load_cleanly() { + let report = load_script_protocols(&shipped_scripts_dir()).unwrap(); + let mut names: Vec<_> = report.loaded.iter().map(|l| l.name.as_str()).collect(); + names.sort_unstable(); + assert_eq!(names, vec!["aneros", "jejoue", "maxpro"]); + assert!( + report.skipped.is_empty(), + "shipped scripts should not be skipped: {:?}", + report + .skipped + .iter() + .map(|s| (&s.source_path, &s.reason)) + .collect::>() + ); +} + +/// AC.4: per-file failures are skipped with structured reasons; remaining +/// scripts still load. +#[test] +fn script_loader_fail_soft() { + let syntax_error = "fn metadata( { #{ }"; // unparseable + let missing_metadata = "fn handle_vibrate(i, s) { [] }"; + let unknown_api_version = r#" +fn metadata() { #{ "protocol": "badversion", "api_version": 99 } } +"#; + let init_state_throws = r#" +fn metadata() { #{ "protocol": "thrower", "api_version": 1 } } +fn init_state() { throw "boom"; } +"#; + let init_state_not_map = r#" +fn metadata() { #{ "protocol": "notmap", "api_version": 1 } } +fn init_state() { 42 } +"#; + let good = r#" +fn metadata() { #{ "protocol": "goodone", "api_version": 1 } } +fn handle_vibrate(i, s) { [ #{ "endpoint": "tx", "data": [1] } ] } +"#; + let dir = write_script_dir(&[ + ("a_syntax_error.rhai", syntax_error), + ("b_missing_metadata.rhai", missing_metadata), + ("c_unknown_api_version.rhai", unknown_api_version), + ("d_init_state_throws.rhai", init_state_throws), + ("e_init_state_not_map.rhai", init_state_not_map), + ("f_good.rhai", good), + ]); + + let report = load_script_protocols(&dir).unwrap(); + + assert_eq!(report.loaded.len(), 1, "only the good script should load"); + assert_eq!(report.loaded[0].name, "goodone"); + assert_eq!(report.skipped.len(), 5, "all five bad fixtures skipped"); + let reasons: Vec = report.skipped.iter().map(|s| s.reason.clone()).collect(); + assert!( + reasons.iter().any(|r| r.contains("parse error")), + "syntax error reason: {reasons:?}" + ); + assert!( + reasons.iter().any(|r| r.contains("metadata")), + "missing metadata reason: {reasons:?}" + ); + assert!( + reasons.iter().any(|r| r.contains("api_version")), + "api version reason: {reasons:?}" + ); + assert!( + reasons + .iter() + .any(|r| r.contains("init_state() failed") || r.contains("init_state() must return")), + "init_state reasons: {reasons:?}" + ); + + // Duplicate protocol names: first (sorted) file wins, later files skipped. + let dup_a = r#" +fn metadata() { #{ "protocol": "dupname", "api_version": 1 } } +fn handle_vibrate(i, s) { [ #{ "endpoint": "tx", "data": [1] } ] } +"#; + let dup_b = r#" +fn metadata() { #{ "protocol": "dupname", "api_version": 1 } } +fn handle_vibrate(i, s) { [ #{ "endpoint": "tx", "data": [2] } ] } +"#; + let dir = write_script_dir(&[("a_first.rhai", dup_a), ("b_second.rhai", dup_b)]); + let report = load_script_protocols(&dir).unwrap(); + assert_eq!(report.loaded.len(), 1); + assert_eq!(report.skipped.len(), 1); + assert!(report.skipped[0].reason.contains("duplicate")); + assert!(report.skipped[0].source_path.ends_with("b_second.rhai")); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// AC.4: `import` and `eval` are contract violations and are rejected. +#[test] +fn script_loader_rejects_import_and_eval() { + let import_script = r#" +import "somewhere" as somewhere; +fn metadata() { #{ "protocol": "importer", "api_version": 1 } } +"#; + let eval_script = r#" +fn metadata() { #{ "protocol": "evaluator", "api_version": 1 } } +fn handle_vibrate(i, s) { eval("[]") } +"#; + let good = r#" +fn metadata() { #{ "protocol": "stillgood", "api_version": 1 } } +"#; + let dir = write_script_dir(&[ + ("a_import.rhai", import_script), + ("b_eval.rhai", eval_script), + ("c_good.rhai", good), + ]); + let report = load_script_protocols(&dir).unwrap(); + assert_eq!(report.loaded.len(), 1); + assert_eq!(report.loaded[0].name, "stillgood"); + let skipped_names: Vec<_> = report + .skipped + .iter() + .map(|s| { + s.source_path + .file_name() + .unwrap() + .to_string_lossy() + .to_string() + }) + .collect(); + assert!(skipped_names.contains(&"a_import.rhai".to_owned())); + assert!(skipped_names.contains(&"b_eval.rhai".to_owned())); + assert!( + report + .skipped + .iter() + .all(|s| s.reason.contains("parse error")), + "import/eval rejection should be a parse error: {:?}", + report.skipped.iter().map(|s| &s.reason).collect::>() + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// AC.4 (converse): a present-but-not-a-directory path errors loudly. +#[test] +fn script_unreadable_directory_fails_finish() { + // A file where a directory is expected. + let file_path = + std::env::temp_dir().join(format!("buttplug-script-not-a-dir-{}", Uuid::new_v4())); + std::fs::write(&file_path, "not a directory").unwrap(); + let result = build_script_protocol_manager(Some(&file_path)); + let error_text = match result { + Ok(_) => panic!("expected a non-directory path to fail"), + Err(message) => message, + }; + assert!(error_text.contains("not a directory"), "{error_text}"); + + // The builder surfaces this as the pinned server error variant. The file + // must still exist here; it is removed at the end of the test. + let dcm = DeviceConfigurationManager::default(); + let mut builder = crate::device::ServerDeviceManagerBuilder::new(dcm); + builder.script_protocol_directory(file_path.clone()); + match builder.finish() { + Err(crate::ButtplugServerError::ScriptProtocolLoadError(_)) => {} + Err(other) => panic!("expected ScriptProtocolLoadError, got {other:?}"), + Ok(_) => panic!("expected ScriptProtocolLoadError, got Ok"), + } + std::fs::remove_file(&file_path).ok(); +} + +/// AC.5: a throwing handler becomes a DeviceSpecificError with the protocol +/// name prefix, never a panic. +#[test] +fn script_handler_runtime_error() { + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { throw "kaboom"; } +"#, + ); + let result = handler.handle_output_vibrate_cmd(0, Uuid::new_v4(), 20); + match result { + Err(ButtplugDeviceError::DeviceSpecificError(message)) => { + assert!( + message.starts_with("Rhai protocol testproto: "), + "message: {message}" + ); + assert!(message.contains("kaboom"), "message: {message}"); + } + other => panic!("expected DeviceSpecificError, got {other:?}"), + } +} + +/// AC.5: invalid command shapes/values are rejected with errors naming the +/// problem: out-of-range byte, unknown endpoint string, wrong return shape. +#[test] +fn script_handler_validation_rejects_bad_commands() { + // Out-of-range byte value. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { [ #{ "endpoint": "tx", "data": [300] } ] } +"#, + ); + match handler.handle_output_vibrate_cmd(0, Uuid::new_v4(), 20) { + Err(ButtplugDeviceError::DeviceSpecificError(message)) => { + assert!(message.contains("Rhai protocol testproto: "), "{message}"); + assert!(message.contains("out of range"), "{message}"); + } + other => panic!("expected DeviceSpecificError, got {other:?}"), + } + + // Unknown endpoint string. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { [ #{ "endpoint": "notanendpoint", "data": [1] } ] } +"#, + ); + match handler.handle_output_vibrate_cmd(0, Uuid::new_v4(), 20) { + Err(ButtplugDeviceError::InvalidEndpoint(endpoint)) => { + assert_eq!(endpoint, "notanendpoint"); + } + other => panic!("expected InvalidEndpoint, got {other:?}"), + } + + // Wrong shape: not an array. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { 42 } +"#, + ); + match handler.handle_output_vibrate_cmd(0, Uuid::new_v4(), 20) { + Err(ButtplugDeviceError::DeviceSpecificError(message)) => { + assert!(message.contains("array of command maps"), "{message}"); + } + other => panic!("expected DeviceSpecificError, got {other:?}"), + } + + // Missing handler → UnhandledCommand (release-mode default policy). + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +"#, + ); + match handler.handle_output_vibrate_cmd(0, Uuid::new_v4(), 20) { + Err(ButtplugDeviceError::UnhandledCommand(_)) => {} + other => panic!("expected UnhandledCommand, got {other:?}"), + } +} + +/// AC.5: an infinite-loop handler terminates via the operation budget. +#[test] +fn script_handler_op_limit_terminates_loop() { + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { + let x = 0; + while true { x += 1; } + [] +} +"#, + ); + match handler.handle_output_vibrate_cmd(0, Uuid::new_v4(), 20) { + Err(ButtplugDeviceError::DeviceSpecificError(message)) => { + assert!(message.contains("Rhai protocol testproto: "), "{message}"); + } + other => panic!("expected DeviceSpecificError, got {other:?}"), + } +} + +/// AC.6: per-connection state isolation — two handlers from the same compiled +/// script keep independent `this` state. +#[test] +fn script_state_isolated_per_connection() { + let source = r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn init_state() { #{ "last": 0 } } +fn handle_vibrate(index, speed) { + this.last = speed; + [ #{ "endpoint": "tx", "data": [this.last & 0xff] } ] +} +"#; + let engine = super::engine::script_engine(); + let ast = Arc::new(engine.compile(source).unwrap()); + let template = { + let mut scope = rhai::Scope::new(); + let mut options = rhai::CallFnOptions::new(); + options.eval_ast = false; + engine + .call_fn_with_options::(options, &mut scope, &ast, "init_state", ()) + .unwrap() + }; + + let handler_a = ScriptedProtocolHandler::new("testproto", ast.clone(), template.flatten_clone()); + let handler_b = ScriptedProtocolHandler::new("testproto", ast.clone(), template.flatten_clone()); + + let commands_a = handler_a + .handle_output_vibrate_cmd(0, Uuid::new_v4(), 0x11) + .unwrap(); + let commands_b = handler_b + .handle_output_vibrate_cmd(0, Uuid::new_v4(), 0x22) + .unwrap(); + + let data = |commands: &Vec| match &commands[0] { + HardwareCommand::Write(write) => write.data().clone(), + _ => panic!("expected write"), + }; + assert_eq!(data(&commands_a), vec![0x11]); + assert_eq!(data(&commands_b), vec![0x22]); + + // State persists within a connection: next call sees the stored value. + let commands_a_next = handler_a + .handle_output_vibrate_cmd(0, Uuid::new_v4(), 0x33) + .unwrap(); + assert_eq!(data(&commands_a_next), vec![0x33]); +} + +/// Regression (review finding): nested state must also be isolated between +/// connections — a mutated nested map/array in one handler must not affect +/// another handler built from the same template. +#[test] +fn script_state_isolated_per_connection_nested() { + let source = r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn init_state() { #{ "nested": #{ "counts": [1, 2] } } } +fn handle_vibrate(index, speed) { + let c = this.nested.counts[0]; + this.nested.counts[0] = c + 1; + let out = this.nested.counts[0]; + [ #{ "endpoint": "tx", "data": [out] } ] +} +"#; + let report = { + // Load through the real loader so the state template is validated. + let dir = write_script_dir(&[("a_nested.rhai", source)]); + let report = load_script_protocols(&dir).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + report + }; + assert_eq!( + report.loaded.len(), + 1, + "skipped: {:?}", + report + .skipped + .iter() + .map(|s| (&s.source_path, &s.reason)) + .collect::>() + ); + let handler_a = report.loaded[0].handler_for_test(); + let handler_b = report.loaded[0].handler_for_test(); + + let data = |commands: &Vec| match &commands[0] { + HardwareCommand::Write(write) => write.data().clone(), + _ => panic!("expected write"), + }; + + // handler_a twice: nested state persists within the connection (1 → 2 → 3). + assert_eq!( + data( + &handler_a + .handle_output_vibrate_cmd(0, Uuid::new_v4(), 0) + .unwrap() + ), + vec![2] + ); + assert_eq!( + data( + &handler_a + .handle_output_vibrate_cmd(0, Uuid::new_v4(), 0) + .unwrap() + ), + vec![3] + ); + // handler_b is unaffected by handler_a's mutations: starts fresh at 1 → 2. + assert_eq!( + data( + &handler_b + .handle_output_vibrate_cmd(0, Uuid::new_v4(), 0) + .unwrap() + ), + vec![2] + ); +} + +/// Regression (review finding): a state template containing a value that +/// does not clone deeply (e.g. a function pointer) is rejected at load time, +/// keeping the per-connection deep-copy guarantee sound. +#[test] +fn script_loader_rejects_non_cloneable_state() { + let fnptr_state = r#" +fn metadata() { #{ "protocol": "fnptrstate", "api_version": 1 } } +fn init_state() { #{ "callback": Fn("metadata") } } +fn handle_vibrate(i, s) { [] } +"#; + let good = r#" +fn metadata() { #{ "protocol": "goodstate", "api_version": 1 } } +"#; + let dir = write_script_dir(&[("a_fnptr_state.rhai", fnptr_state), ("b_good.rhai", good)]); + let report = load_script_protocols(&dir).unwrap(); + assert_eq!(report.loaded.len(), 1); + assert_eq!(report.loaded[0].name, "goodstate"); + assert_eq!(report.skipped.len(), 1); + assert!( + report.skipped[0].reason.contains("unsupported value"), + "reason: {}", + report.skipped[0].reason + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// AC.6b: command-id semantics — default forwards the feature id; scripts can +/// override with fixed UUIDs (jejoue's protocol UUID). +#[test] +fn script_command_id_default_and_override() { + let feature_id = Uuid::new_v4(); + + // Default: the incoming feature id is forwarded. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { [ #{ "endpoint": "tx", "data": [1] } ] } +"#, + ); + let commands = handler + .handle_output_vibrate_cmd(0, feature_id, 20) + .unwrap(); + let HardwareCommand::Write(write) = &commands[0] else { + panic!("expected write"); + }; + assert_eq!( + write.command_id(), + &std::collections::HashSet::from([feature_id]) + ); + + // Override: fixed protocol UUID. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { + [ #{ "endpoint": "tx", "data": [1], "command_ids": ["d3dd2bf5-b029-4bc1-9466-39f82c2e3258"] } ] +} +"#, + ); + let commands = handler + .handle_output_vibrate_cmd(0, feature_id, 20) + .unwrap(); + let HardwareCommand::Write(write) = &commands[0] else { + panic!("expected write"); + }; + assert_eq!( + write.command_id(), + &std::collections::HashSet::from([uuid::uuid!("d3dd2bf5-b029-4bc1-9466-39f82c2e3258")]) + ); + + // Invalid UUID in command_ids is rejected. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { + [ #{ "endpoint": "tx", "data": [1], "command_ids": ["not-a-uuid"] } ] +} +"#, + ); + let result = handler.handle_output_vibrate_cmd(0, feature_id, 20); + match result { + Err(ButtplugDeviceError::DeviceSpecificError(message)) => { + assert!(message.contains("not a valid UUID"), "{message}"); + } + other => panic!("expected DeviceSpecificError, got {other:?}"), + } +} + +/// AC.7(b): feature on, no directory configured — built-in behavior is +/// untouched. +#[test] +fn script_feature_without_directory_is_inert() { + let manager = build_script_protocol_manager(None).unwrap(); + // The default manager's specializer behavior is exercised through the + // internal device config: the aneros protocol still resolves. + let dcm: DeviceConfigurationManager = load_protocol_configs(&None, &None, false) + .unwrap() + .finish() + .unwrap(); + let specifier = ProtocolCommunicationSpecifier::BluetoothLE( + BluetoothLESpecifier::new_from_device("Massage Demo", &HashMap::new(), &[]), + ); + let specializers = manager.protocol_specializers( + &specifier, + dcm.base_communication_specifiers(), + dcm.user_communication_specifiers(), + ); + assert!(!specializers.is_empty()); +} + +/// Loads one of the shipped protocol scripts as a handler. +fn shipped_handler(protocol: &str) -> std::sync::Arc { + let report = load_script_protocols(&shipped_scripts_dir()).unwrap(); + report + .loaded + .iter() + .find(|l| l.name == protocol) + .unwrap_or_else(|| panic!("shipped script {protocol} should load")) + .handler_for_test() +} + +/// Extracts the write commands' command-id sets for parity comparison +/// (`HardwareWriteCmd::PartialEq` intentionally ignores command ids). +fn command_ids(commands: &[HardwareCommand]) -> Vec> { + commands + .iter() + .map(|command| match command { + HardwareCommand::Write(write) => write.command_id().clone(), + _ => panic!("expected write command"), + }) + .collect() +} + +/// AC.2(a)/AC.3: the shipped aneros/jejoue/maxpro scripts produce +/// byte-identical hardware writes (including command ids) to the Rust +/// implementations across meaningful input sequences. For jejoue the +/// sequence exercises every pattern-selection branch. +#[test] +fn script_parity_with_rust_impls() { + use crate::device::protocol_impl::{aneros, jejoue, maxpro}; + + let feature_id = Uuid::new_v4(); + + // --- aneros: stateless, two indices, several speeds. + let rust_handler = aneros::Aneros::default(); + let script_handler = shipped_handler("aneros"); + for (index, speed) in [(0, 0u32), (0, 64), (1, 13), (1, 127), (0, 0)] { + let rust_commands = rust_handler + .handle_output_vibrate_cmd(index, feature_id, speed) + .unwrap(); + let script_commands = script_handler + .handle_output_vibrate_cmd(index, feature_id, speed) + .unwrap(); + assert_eq!(rust_commands, script_commands, "aneros ({index}, {speed})"); + assert_eq!( + command_ids(&rust_commands), + command_ids(&script_commands), + "aneros command ids ({index}, {speed})" + ); + } + + // --- maxpro: CRC computation across speeds. + let rust_handler = maxpro::Maxpro::default(); + let script_handler = shipped_handler("maxpro"); + for speed in [0u32, 1, 50, 100] { + let rust_commands = rust_handler + .handle_output_vibrate_cmd(0, feature_id, speed) + .unwrap(); + let script_commands = script_handler + .handle_output_vibrate_cmd(0, feature_id, speed) + .unwrap(); + assert_eq!(rust_commands, script_commands, "maxpro ({speed})"); + assert_eq!( + command_ids(&rust_commands), + command_ids(&script_commands), + "maxpro command ids ({speed})" + ); + } + + // --- jejoue: stateful; drive all four pattern branches. + // Branch sequence: (index 1 nonzero from zero) → [3, s1]; + // (index 0 nonzero while 1 active) → [2, s0]; (index 0 back to zero with 1 + // still active) → [3, s1]; (both zero) → [1, 0]. + let rust_handler = jejoue::JeJoue::default(); + let script_handler = shipped_handler("jejoue"); + let sequence = [(0u32, 3u32), (1, 3), (0, 0), (1, 0)]; + for (index, speed) in sequence { + let rust_commands = rust_handler + .handle_output_vibrate_cmd(index, feature_id, speed) + .unwrap(); + let script_commands = script_handler + .handle_output_vibrate_cmd(index, feature_id, speed) + .unwrap(); + assert_eq!(rust_commands, script_commands, "jejoue ({index}, {speed})"); + assert_eq!( + command_ids(&rust_commands), + command_ids(&script_commands), + "jejoue command ids ({index}, {speed})" + ); + } + + // Both-zero stop case from a running state (matches the YAML's final stop). + let rust_commands = rust_handler + .handle_output_vibrate_cmd(1, feature_id, 0) + .unwrap(); + let script_commands = script_handler + .handle_output_vibrate_cmd(1, feature_id, 0) + .unwrap(); + assert_eq!(rust_commands, script_commands, "jejoue stop"); +} + +/// Review finding: `sleep` blocks threads without consuming operations, so +/// it must be replaced with an error at both handler- and load-time. +#[test] +fn script_handler_sleep_is_blocked() { + // Handler-level: the call errors immediately instead of sleeping. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { sleep(999999999); [] } +"#, + ); + match handler.handle_output_vibrate_cmd(0, Uuid::new_v4(), 20) { + Err(ButtplugDeviceError::DeviceSpecificError(message)) => { + assert!(message.contains("sleep is disabled"), "{message}"); + } + other => panic!("expected DeviceSpecificError, got {other:?}"), + } + + // Load-level: a script that sleeps in metadata() is skipped at load time. + let sleeper = r#" +fn metadata() { sleep(999999999); #{ "protocol": "sleeper", "api_version": 1 } } +"#; + let good = r#" +fn metadata() { #{ "protocol": "awake", "api_version": 1 } } +"#; + let dir = write_script_dir(&[("a_sleeper.rhai", sleeper), ("b_good.rhai", good)]); + let report = load_script_protocols(&dir).unwrap(); + assert_eq!(report.loaded.len(), 1); + assert_eq!(report.loaded[0].name, "awake"); + assert_eq!(report.skipped.len(), 1); + assert!( + report.skipped[0].reason.contains("sleep is disabled"), + "reason: {}", + report.skipped[0].reason + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// Review finding: pin the remaining command-conversion branches — multiple +/// returned commands, Blob data, write_with_response, and field defaults. +#[test] +fn script_handler_conversion_table() { + let feature_id = Uuid::new_v4(); + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { + [ + #{ + "endpoint": "tx", + "data": blob(3, 0x42), + "write_with_response": true, + "command_ids": ["d3dd2bf5-b029-4bc1-9466-39f82c2e3258"], + }, + #{ "endpoint": "rx", "data": [1, 2, 3] }, + ] +} +"#, + ); + let commands = handler + .handle_output_vibrate_cmd(0, feature_id, 20) + .unwrap(); + assert_eq!(commands.len(), 2, "both commands are returned in order"); + + let HardwareCommand::Write(first) = &commands[0] else { + panic!("expected write"); + }; + assert_eq!(first.endpoint(), Endpoint::Tx); + assert_eq!(first.data(), &vec![0x42, 0x42, 0x42]); + assert!(first.write_with_response()); + assert_eq!( + first.command_id(), + &std::collections::HashSet::from([uuid::uuid!("d3dd2bf5-b029-4bc1-9466-39f82c2e3258")]) + ); + + let HardwareCommand::Write(second) = &commands[1] else { + panic!("expected write"); + }; + assert_eq!(second.endpoint(), Endpoint::Rx); + assert_eq!(second.data(), &vec![1, 2, 3]); + assert!(!second.write_with_response(), "defaults to false"); + assert_eq!( + second.command_id(), + &std::collections::HashSet::from([feature_id]), + "defaults to the feature id" + ); +} + +/// Review finding: pin the signed-valued handler dispatches and the +/// three-argument position-with-duration dispatch. +#[test] +fn script_handler_dispatch_variants() { + let feature_id = Uuid::new_v4(); + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_rotate(index, speed) { [ #{ "endpoint": "tx", "data": [index, speed & 0xff] } ] } +fn handle_temperature(index, level) { [ #{ "endpoint": "tx", "data": [index, level & 0xff] } ] } +fn handle_position_duration(index, position, duration_ms) { + [ #{ "endpoint": "tx", "data": [index, position & 0xff, duration_ms & 0xff] } ] +} +"#, + ); + let data = |commands: &Vec| match &commands[0] { + HardwareCommand::Write(write) => write.data().clone(), + _ => panic!("expected write"), + }; + + // Signed rotate value reaches the script intact (negative wraps via & 0xff). + assert_eq!( + data(&handler.handle_output_rotate_cmd(1, feature_id, -5).unwrap()), + vec![1, 0xFB] + ); + // Signed temperature value. + assert_eq!( + data( + &handler + .handle_output_temperature_cmd(0, feature_id, -2) + .unwrap() + ), + vec![0, 0xFE] + ); + // Three-arg position with duration. + assert_eq!( + data( + &handler + .handle_hw_position_with_duration_cmd(2, feature_id, 200, 1234) + .unwrap() + ), + vec![2, 200, 210] + ); +} + +/// Review finding: rhai integer division stays integer, but a float anywhere +/// in command data must be rejected (no silent truncation). +#[test] +fn script_handler_rejects_float_data() { + let feature_id = Uuid::new_v4(); + + // INT / FLOAT literal produces a float: rejected. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { [ #{ "endpoint": "tx", "data": [speed / 2.0] } ] } +"#, + ); + match handler.handle_output_vibrate_cmd(0, feature_id, 50) { + Err(ButtplugDeviceError::DeviceSpecificError(message)) => { + assert!(message.contains("must be an integer"), "{message}"); + } + other => panic!("expected DeviceSpecificError, got {other:?}"), + } + + // INT / INT stays integer division: accepted. + let handler = handler_from_source( + r#" +fn metadata() { #{ "protocol": "testproto", "api_version": 1 } } +fn handle_vibrate(index, speed) { [ #{ "endpoint": "tx", "data": [speed / 2] } ] } +"#, + ); + let commands = handler + .handle_output_vibrate_cmd(0, feature_id, 50) + .unwrap(); + let HardwareCommand::Write(write) = &commands[0] else { + panic!("expected write"); + }; + assert_eq!(write.data(), &vec![25]); +} diff --git a/crates/buttplug_server/src/device/server_device_manager.rs b/crates/buttplug_server/src/device/server_device_manager.rs index 734e87df1..3bb74e5e1 100644 --- a/crates/buttplug_server/src/device/server_device_manager.rs +++ b/crates/buttplug_server/src/device/server_device_manager.rs @@ -80,6 +80,7 @@ pub struct ServerDeviceManagerBuilder { device_configuration_manager: Arc, comm_managers: Vec>, emit_output_observations: bool, + script_protocol_directory: Option, } impl ServerDeviceManagerBuilder { @@ -88,6 +89,7 @@ impl ServerDeviceManagerBuilder { device_configuration_manager: Arc::new(device_configuration_manager), comm_managers: vec![], emit_output_observations: false, + script_protocol_directory: None, } } @@ -98,9 +100,19 @@ impl ServerDeviceManagerBuilder { device_configuration_manager, comm_managers: vec![], emit_output_observations: false, + script_protocol_directory: None, } } + /// Directory to load rhai script protocols from (requires the + /// `rhai-protocols` feature). Script protocols are registered alongside the + /// built-in protocols and override same-name built-ins. A missing directory + /// is a no-op; an unreadable/non-directory path fails `finish()`. + pub fn script_protocol_directory(&mut self, directory: std::path::PathBuf) -> &mut Self { + self.script_protocol_directory = Some(directory); + self + } + pub fn comm_manager(&mut self, builder: T) -> &mut Self where T: HardwareCommunicationManagerBuilder + 'static, @@ -134,6 +146,30 @@ impl ServerDeviceManagerBuilder { self } + /// Builds the protocol manager for the event loop, incorporating script + /// protocols when a script protocol directory is configured and script + /// protocol support is compiled in. + fn build_protocol_manager( + &self, + ) -> Result { + #[cfg(all(feature = "rhai-protocols", not(target_arch = "wasm32")))] + { + use crate::device::protocol_impl::script::build_script_protocol_manager; + build_script_protocol_manager(self.script_protocol_directory.as_deref()) + .map_err(ButtplugServerError::ScriptProtocolLoadError) + } + #[cfg(not(all(feature = "rhai-protocols", not(target_arch = "wasm32"))))] + { + if let Some(directory) = &self.script_protocol_directory { + warn!( + "Script protocol support is not compiled into this build; ignoring script protocol directory {}", + directory.display() + ); + } + Ok(crate::device::protocol::ProtocolManager::default()) + } + } + pub fn finish(&mut self) -> Result { let (device_command_sender, device_command_receiver) = mpsc::channel(256); let (device_event_sender, device_event_receiver) = mpsc::channel(256); @@ -191,6 +227,7 @@ impl ServerDeviceManagerBuilder { }; let task_group = TaskGroup::new(); + let protocol_manager = self.build_protocol_manager()?; let mut event_loop = ServerDeviceManagerEventLoop::new( comm_managers, self.device_configuration_manager.clone(), @@ -201,6 +238,7 @@ impl ServerDeviceManagerBuilder { device_command_receiver, output_observation_sender.clone(), task_group.clone(), + protocol_manager, ); // The event loop is the device manager's owned long-running task; spawning // it into the manager's TaskGroup lets shutdown cancel-then-join it diff --git a/crates/buttplug_server/src/device/server_device_manager_event_loop.rs b/crates/buttplug_server/src/device/server_device_manager_event_loop.rs index e6638401c..7d8112082 100644 --- a/crates/buttplug_server/src/device/server_device_manager_event_loop.rs +++ b/crates/buttplug_server/src/device/server_device_manager_event_loop.rs @@ -101,6 +101,7 @@ impl ServerDeviceManagerEventLoop { device_command_receiver: mpsc::Receiver, output_observation_sender: Option>, task_group: TaskGroup, + protocol_manager: ProtocolManager, ) -> Self { let (device_event_sender, device_event_receiver) = mpsc::channel(256); Self { @@ -116,7 +117,7 @@ impl ServerDeviceManagerEventLoop { connecting_devices: Arc::new(DashSet::new()), loop_cancellation_token, task_group, - protocol_manager: ProtocolManager::default(), + protocol_manager, output_observation_sender, } } diff --git a/crates/buttplug_server/src/lib.rs b/crates/buttplug_server/src/lib.rs index 37e4cb9a5..e13b033da 100644 --- a/crates/buttplug_server/src/lib.rs +++ b/crates/buttplug_server/src/lib.rs @@ -94,4 +94,9 @@ pub enum ButtplugServerError { /// Requested protocol has not been registered with the system. #[error("Buttplug Protocol of type {0} does not exist in the system and cannot be removed.")] ProtocolDoesNotExist(String), + /// Script protocol directory could not be loaded (unreadable or not a + /// directory). Individual script files that fail validation are skipped + /// with a warning instead of raising this error. + #[error("Script protocol directory could not be loaded: {0}")] + ScriptProtocolLoadError(String), } diff --git a/crates/buttplug_tests/CLAUDE.md b/crates/buttplug_tests/CLAUDE.md index 31f5b50a1..fc30a8929 100644 --- a/crates/buttplug_tests/CLAUDE.md +++ b/crates/buttplug_tests/CLAUDE.md @@ -27,6 +27,7 @@ The bulk of this crate is **data-driven device protocol tests** in `tests/test_d YAML test case structure (`DeviceTestCase`): - `devices` — list of test device identifiers and expected names - `device_config_file` / `user_device_config_file` — optional custom config overrides +- `script_protocol_directory` — optional directory of `.rhai` protocol scripts loaded into the server for the test, resolved relative to the crate manifest; see [Script Protocol API v1](../buttplug_server/docs/script-protocols.md) - `device_init` — initialization sequence (subscribe, write handshake bytes, receive notifications) - `device_commands` — sequence of `Messages` (client commands like Vibrate/Scalar/Stop), `Commands` (expected hardware writes), and `Events` (simulated device notifications) diff --git a/crates/buttplug_tests/Cargo.toml b/crates/buttplug_tests/Cargo.toml index ba3351fc0..9d0182b6e 100644 --- a/crates/buttplug_tests/Cargo.toml +++ b/crates/buttplug_tests/Cargo.toml @@ -14,7 +14,7 @@ edition = "2024" buttplug_core = { version = "11.0.0", path = "../buttplug_core" } buttplug_client = { version = "11.0.0", path = "../buttplug_client" } buttplug_client_in_process = { version = "11.0.0", path = "../buttplug_client_in_process", default-features = false} -buttplug_server = { version = "11.0.0", path = "../buttplug_server" } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", features = ["rhai-protocols"] } buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } log = "0.4.33" tokio = { version = "1.53.1", features = ["macros"] } diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 3d22c116b..f2ad5caad 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -149,6 +149,9 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_embedded_v4(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -280,6 +283,9 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_json_v4(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -410,6 +416,9 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_embedded_v3(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -541,6 +550,9 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_json_v3(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -663,6 +675,9 @@ async fn test_device_protocols_json_v3(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_embedded_v2(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -786,6 +801,9 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_json_v2(test_file: &str) { util::device_test::client::client_v2::run_json_test_case(&load_test_case(test_file).await).await; @@ -907,6 +925,9 @@ async fn test_device_protocols_json_v2(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_embedded_v1(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -1029,6 +1050,9 @@ async fn test_device_protocols_embedded_v1(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_jejoue_protocol.yaml" ; "Je Joue Protocol")] +#[test_case("test_maxpro_protocol.yaml" ; "Maxpro Protocol")] +#[test_case("test_script_protocol.yaml" ; "Scripted Novel Protocol")] #[tokio::test] async fn test_device_protocols_json_v1(test_file: &str) { util::device_test::client::client_v1::run_json_test_case(&load_test_case(test_file).await).await; diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs index 264496f26..9412ca330 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs @@ -105,10 +105,17 @@ fn build_server(test_case: &DeviceTestCase) -> (ButtplugServer, Vec (ButtplugServer, Vec (ButtplugServer, Vec (ButtplugServer, Vec (ButtplugServer, Vec, device_config_file: Option, user_device_config_file: Option, + /// Directory of `.rhai` protocol scripts to load into the server (resolved + /// relative to the crate manifest, like the config file fields). + script_protocol_directory: Option, device_init: Option>, device_commands: Vec, }