TL;DR - We use Rustyscript to sandbox and execute user-submitted JS code inside our Rust application
Superposition is a context-aware configuration management system that ensures correct, safe, and reliable config changes. One of the ways we've achieved this goal is by using jsonschema types for all configuration values with a default value. We call this concept Default Configs. We soon realized an issue - though the type is right, that does not guarantee the correctness of the actual value.
Take the following configuration:
{
"image_url": "https://example.com/image/cat.png"
}
The strictest JSON schema for the value of image_url would be:
{
"type": "string",
"pattern": "^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$"
}
This schema validates the type and shape and that is one part of correctness, but we're missing the most critical aspect - does the URL exist? Does it return what we're expecting?
User Defined Validation Functions!
We introduced Functions - a way for any end user to write validation logic and link them to a Default Config or Dimension to ensure the correctness of that value (should they want to do that). These functions are written in our frontend monaco editor and saved to a database with some other information. The function looks like this:
async function execute(payload) {
return true;
}
If the function returns false, the value being set is invalid and Superposition will not allow it to be saved as a configuration change.
First implementation - Node.js Isolates
Since we just needed a boolean return value, we wrapped the user code with Isolates and ran it as a Node.js script. If the function returned false, the code forced the script to terminate with a custom exit code. When this was run through Rust's std::process::Command, we checked the exit code and interpreted the return value of the function. Users also asked for logs, so console.log would output to stdout, which was also parsed and returned to the user. Running under Node.js let us install npm packages (e.g. axios for HTTP) and use it alongside the script we were running.
When a function is being edited/created, we also ensured that the function the user provided is valid.
This was a simple solution we tried initially and it worked well - until users wanted more capabilities. One capability was Value Compute Functions - functions that returned a vector of values. Users wanted to select a value from a dynamic list of potential values, fetched from internal systems at Juspay, that changes frequently. Another downside to our Node.js isolate solution was a lot of wrapper code to ensure that we properly isolated user code. We didn't validate if the code that was being stored was valid syntactically, and if Node.js could not execute it, we didn't return that error to the user.
Other options
The team had previously looked into mlua, which we decided to not pursue since Superposition users were more familiar with Javascript and looked at ways to run Javascript from within Rust to have better interoperability. Two options we considered were rquickjs and Rustyscript; we went with Rustyscript because of its documentation, features, and better compatibility with Node.js.
Running Javascript in Rust with Rustyscript
Rustyscript is powerful and extremely customizable for running user code - it has a permissions model, functions that you can expose from Rust to use in JS land, and some very nice built-in functionality like console, crypto, http, etc.
Here's a function that validates a URL by fetching it and checking that the response status is 200:
async function execute(payload) {
const { value_validate } = payload;
const { key, value, type, environment } = value_validate;
let response = await fetch(value);
return response.status === 200;
}
All functions in Superposition are named execute. This is because, while validating the user input, we wrap this code with some helpers and the final code that is passed to Rustyscript looks like:
let logBuffer = [];
const originalConsole = console;
const customConsole = {
log: (...args) => {
logBuffer.push("[log] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
},
info: (...args) => {
logBuffer.push("[info] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
},
warn: (...args) => {
logBuffer.push("[warn] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
},
error: (...args) => {
logBuffer.push("[error] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
},
debug: (...args) => {
logBuffer.push("[debug] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
}
};
Object.defineProperty(globalThis, 'console', {
value: customConsole,
writable: false,
configurable: false
});
function getLogBuffer() {
return logBuffer;
}
function clearLogBuffer() {
logBuffer = [];
}
async function execute(payload) {
const { value_validate } = payload;
const { key, value, type, environment } = value_validate;
let response = await fetch(value);
return response.status === 200;
}
export { execute, getLogBuffer, clearLogBuffer };
Note the console override — it routes every log call into an in-memory buffer that we expose via getLogBuffer.
We validate the syntax of the above Javascript with rustyscript::validate, which takes a string and tells you if it is valid syntax or not. If it is not valid, we return an error to the user. This is done before we even try to run the function:
rustyscript::validate(&type_check_code).map_err(|err| {
log::error!("Invalid function syntax: {:?}", err);
bad_argument!("Invalid function syntax: {}", err)
})?;
This validates the syntax, but is execute a function and does it return a value that Superposition expects from this category of function? With rustyscript, we can simply just run the function with some default inputs and inspect its return value. This checks:
execute exists
execute is a function
execute returns a value of the expected type (boolean for validation functions, array for value-compute functions)
- the function has handled potential null/undefined values correctly
To run a function in Rustyscript we need to:
- Create a basic runtime
- Load a Javascript module
- Call a function and consume the resulting value as a Rust type
let type_check_code = generate_wrapped_code(&stubbed_code);
rustyscript::validate(&type_check_code).map_err(|err| {
log::error!("Invalid function syntax: {:?}", err);
bad_argument!("Invalid function syntax: {}", err)
})?;
let module = Module::new("type_check.js", &type_check_code);
let runtime_options = RuntimeOptions {
timeout: Duration::from_millis(1500),
..Default::default()
};
let mut runtime = Runtime::new(runtime_options).map_err(|e| {
let err_str = e.to_string();
validation_error!("Failed to create runtime: {}", err_str)
})?;
let module_handle = runtime.load_module(&module).map_err(|e| {
let err_str = e.to_string();
validation_error!("Failed to load module: {}", err_str)
})?;
let tokio_runtime = runtime.tokio_runtime();
tokio_runtime
.block_on(async {
let environment = FunctionEnvironment {
context: serde_json::Map::new(),
overrides: serde_json::Map::new(),
};
match fn_type {
FunctionType::ValueCompute => {
let payload = FunctionExecutionRequest::ValueComputeFunctionRequest {
name: String::new(),
prefix: String::new(),
r#type: superposition_types::api::functions::KeyType::ConfigKey,
environment,
};
let serde_json::Value::Array(_) = runtime
.call_function_async::<serde_json::Value>(
Some(&module_handle),
"execute",
json_args!(payload),
)
.await? else {
return Err(rustyscript::Error::Runtime("The value compute function did not return an array".to_string()));
};
Ok(())
}
other => {
let payload = match other {
FunctionType::ValueValidation => FunctionExecutionRequest::ValueValidationFunctionRequest {
key: String::new(),
value: serde_json::Value::String(String::new()),
r#type:
KeyType::ConfigKey,
environment,
},
FunctionType::ContextValidation => FunctionExecutionRequest::ContextValidationFunctionRequest {
environment,
trigger_reason: ContextValidationTrigger::Context,
},
FunctionType::ChangeReasonValidation => FunctionExecutionRequest::ChangeReasonValidationFunctionRequest {
change_reason: ChangeReason::default(),
},
_ => {
return Err(rustyscript::Error::Runtime("An invalid situation occurred in the runtime".to_string()))
}
};
let serde_json::Value::Bool(_) = runtime
.call_function_async::<serde_json::Value>(
Some(&module_handle),
"execute",
json_args!(payload),
)
.await? else {
return Err(rustyscript::Error::Runtime("The function did not return a boolean".to_string()))
};
Ok(())
}
}
})
.map_err(|e| {
let err_str = e.to_string();
validation_error!("Function validation failed: {}", err_str)
})?;
Now when this function needs to be run to validate any input or change, it is run with actual inputs that the system infers based on context. This context could be derived or explicitly stated by the user.
When running this function with user inputs, we use the same code as above, but instead of using a stubbed payload, we use the actual payload that is being validated. The code looks like this:
let wrapped_code = generate_wrapped_code(&code.0);
let module = Module::new("function.js", &wrapped_code);
let runtime_options = RuntimeOptions {
timeout: Duration::from_millis(1500),
..Default::default()
};
let mut runtime = Runtime::new(runtime_options).map_err(|e| {
let err_str = e.to_string();
(format!("Failed to create runtime: {}", err_str), None)
})?;
let payload = FunctionPayload {
version: runtime_version,
payload: args.clone(),
};
let module_handle = runtime
.load_module(&module)
.map_err(|err| (err.to_string(), None))?;
let tokio_runtime = runtime.tokio_runtime();
let fn_output = tokio_runtime
.block_on(async {
runtime
.call_function_async::<serde_json::Value>(
Some(&module_handle),
"execute",
json_args!(payload),
)
.await
})
.map_err(|err| (err.to_string(), None))?;
let stdout = runtime
.call_function::<Vec<String>>(Some(&module_handle), "getLogBuffer", json_args!())
.map_err(|err| (err.to_string(), None))?
.join("\n");
runtime
.call_function::<()>(Some(&module_handle), "clearLogBuffer", json_args!())
.map_err(|err| (err.to_string(), None))?;
Conclusion
Rustyscript can do a lot more than what we've shared here - I recommend taking a look at the documentation if you've never used it before. For Superposition, Rustyscript's ability to run isolated JavaScript seamlessly from within Rust has unlocked richer correctness guarantees — we now support both validation and value-compute functions with full syntax checking and captured logs, without spawning a subprocess.
If you like the idea of Superposition, do take a look at our docs to learn more. Have questions? Join our Slack to interact with the team.