use std::cell::RefCell;
use rhai::Engine;
use super::ScriptOutput;
const MAX_OPERATIONS: u64 = 10_000_000;
thread_local! {
pub(super) static ENGINE: Engine = build_engine();
static OUTPUT: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
pub(super) fn capture<T>(body: impl FnOnce(&Engine) -> T) -> (T, ScriptOutput) {
OUTPUT.with(|output| output.borrow_mut().clear());
let result = ENGINE.with(body);
let lines = OUTPUT.with(|output| std::mem::take(&mut *output.borrow_mut()));
(result, ScriptOutput { lines })
}
fn build_engine() -> Engine {
let mut engine = Engine::new();
engine.on_print(|text| push_output(text.to_string()));
engine.on_debug(|text, source, position| {
push_output(match source {
Some(source) => format!("{source} @ {position:?}: {text}"),
None => format!("{position:?}: {text}"),
})
});
engine.disable_symbol("eval");
engine.set_max_operations(MAX_OPERATIONS);
engine.register_fn("base64_encode", base64_encode);
engine.register_fn("base64_decode", base64_decode);
engine.register_fn("hmac_sha256", hmac_sha256);
engine.register_fn("uuid", uuid_v4);
engine.register_fn("now", now_millis);
engine
}
fn push_output(line: String) {
OUTPUT.with(|output| output.borrow_mut().push(line));
}
fn base64_encode(input: &str) -> String {
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, input)
}
fn base64_decode(input: &str) -> Result<String, Box<rhai::EvalAltResult>> {
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, input)
.map_err(|err| format!("invalid base64: {err}"))?;
String::from_utf8(bytes).map_err(|_| "decoded base64 is not valid UTF-8".into())
}
fn hmac_sha256(key: &str, message: &str) -> String {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let mut mac =
Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("HMAC accepts a key of any length");
mac.update(message.as_bytes());
hex_encode(&mac.finalize().into_bytes())
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn uuid_v4() -> String {
uuid::Uuid::new_v4().to_string()
}
fn now_millis() -> i64 {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("the system clock is set after the Unix epoch")
.as_millis();
millis as i64
}
pub(super) fn failure_message(err: &rhai::EvalAltResult) -> String {
match err {
rhai::EvalAltResult::ErrorRuntime(value, _) => value.to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{
post, pre, response, run_pre, with_post_request, with_pre_request,
};
use crate::{ScriptOutcome, Scripts, SendraError};
#[test]
fn a_script_cannot_import_a_module() {
let request = with_pre_request(r#"import "os" as os;"#);
let err = Scripts::compile(&request).expect_err("`import` must not exist");
assert!(matches!(err, SendraError::ScriptParse { .. }), "{err:?}");
}
#[test]
fn a_script_cannot_eval_a_string() {
let request = with_pre_request(r#"eval("1 + 1");"#);
let err = Scripts::compile(&request).expect_err("`eval` is disabled");
assert!(matches!(err, SendraError::ScriptParse { .. }), "{err:?}");
}
#[test]
fn nothing_that_touches_the_filesystem_or_the_network_is_reachable() {
for attempt in [
r#"open_file("/etc/passwd");"#,
r#"read_file("/etc/passwd");"#,
r#"http_get("https://example.com");"#,
r#"fetch("https://example.com");"#,
r#"system("ls");"#,
r#"exec("ls");"#,
r#"spawn("ls");"#,
r#"env("HOME");"#,
r#"read_dir(".");"#,
] {
let request = with_pre_request(attempt);
let err =
run_pre(&request).expect_err(&format!("`{attempt}` must not resolve to anything"));
match &err {
SendraError::ScriptParse { .. } => {}
SendraError::ScriptFailed { message, .. } => assert!(
message.contains("not found") || message.contains("Function"),
"`{attempt}` failed with {message}, which does not read as \"no such function\""
),
other => panic!("`{attempt}` produced {other:?}"),
}
}
}
#[test]
fn base64_encode_and_decode_round_trip_a_known_pair() {
let request = with_pre_request(
r#"if base64_encode("hello") != "aGVsbG8=" { throw "encode mismatch"; }
if base64_decode("aGVsbG8=") != "hello" { throw "decode mismatch"; }
request.headers["X-Encoded"] = base64_encode(request.body);"#,
);
let resolved = run_pre(&request).expect("both known-vector checks pass");
assert_eq!(resolved.header("X-Encoded"), Some("eyJpZCI6MX0="));
}
#[test]
fn base64_decode_of_invalid_input_throws_rather_than_panics() {
let request = with_pre_request(r#"base64_decode("not valid base64 !!!");"#);
let err = run_pre(&request).expect_err("invalid base64 must not silently decode");
match err {
SendraError::ScriptFailed { message, .. } => {
assert!(message.contains("invalid base64"), "got {message:?}");
}
other => panic!("expected ScriptFailed, got {other:?}"),
}
}
#[test]
fn base64_decode_of_bytes_that_are_not_utf8_throws() {
let request = with_pre_request(r#"base64_decode("//4=");"#);
let err = run_pre(&request).expect_err("non-UTF-8 decoded bytes must not become a string");
match err {
SendraError::ScriptFailed { message, .. } => {
assert!(message.contains("not valid UTF-8"), "got {message:?}");
}
other => panic!("expected ScriptFailed, got {other:?}"),
}
}
#[test]
fn hmac_sha256_matches_a_known_test_vector() {
let request = with_pre_request(
r#"let signature = hmac_sha256("key", "The quick brown fox jumps over the lazy dog");
if signature != "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" {
throw "signature mismatch: " + signature;
}"#,
);
run_pre(&request).expect("the known vector must match");
}
#[test]
fn hmac_sha256_is_the_canonical_use_case_signing_a_header() {
let request = with_pre_request(
r#"request.headers["X-Signature"] = hmac_sha256("shared-secret", request.body);"#,
);
let resolved = run_pre(&request).expect("signing must not fail");
let signature = resolved.header("X-Signature").expect("the header was set");
assert_eq!(signature.len(), 64);
assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn uuid_produces_a_well_formed_random_v4_uuid_each_call() {
let request = with_pre_request(
r#"let a = uuid();
let b = uuid();
if a == b { throw "two calls produced the same uuid"; }
request.headers["X-A"] = a;
request.headers["X-B"] = b;"#,
);
let resolved = run_pre(&request).expect("uuid() must not fail");
for header in ["X-A", "X-B"] {
let value = resolved.header(header).unwrap();
assert_eq!(value.len(), 36, "got {value:?}");
assert_eq!(value.as_bytes()[14], b'4', "not a v4 uuid: {value:?}");
assert!(
matches!(value.as_bytes()[19], b'8' | b'9' | b'a' | b'b'),
"not a valid v4 variant nibble: {value:?}"
);
}
}
#[test]
fn now_returns_a_plausible_current_unix_millisecond_timestamp() {
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let request = with_pre_request(r#"request.headers["X-Now"] = now().to_string();"#);
let resolved = run_pre(&request).expect("now() must not fail");
let after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let reported: i64 = resolved.header("X-Now").unwrap().parse().unwrap();
assert!(
(before..=after).contains(&reported),
"now() = {reported}, expected between {before} and {after}"
);
}
#[test]
fn none_of_the_new_functions_provide_a_filesystem_network_or_process_wedge() {
let request = with_pre_request(r#"base64_decode("/etc/passwd");"#);
assert!(
run_pre(&request).is_err(),
"a path is not valid base64 — this must fail exactly like any other invalid input"
);
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(b"/etc/passwd").unwrap();
mac.update(b"/etc/shadow");
let expected_signature = super::hex_encode(&mac.finalize().into_bytes());
let expected_encoded =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, "/etc/shadow");
let request = with_pre_request(
r#"request.headers["Sig"] = hmac_sha256("/etc/passwd", "/etc/shadow");
request.headers["Enc"] = base64_encode("/etc/shadow");"#,
);
let resolved = run_pre(&request).expect("both are ordinary string/byte computations");
assert_eq!(resolved.header("Sig"), Some(expected_signature.as_str()));
assert_eq!(resolved.header("Enc"), Some(expected_encoded.as_str()));
for attempt in [
r#"open_file("/etc/passwd");"#,
r#"read_file("/etc/passwd");"#,
r#"http_get("https://example.com");"#,
r#"env("HOME");"#,
] {
let request = with_pre_request(attempt);
let err =
run_pre(&request).expect_err(&format!("`{attempt}` must still resolve to nothing"));
match &err {
SendraError::ScriptParse { .. } => {}
SendraError::ScriptFailed { message, .. } => assert!(
message.contains("not found") || message.contains("Function"),
"`{attempt}` failed with {message}, which does not read as \"no such function\""
),
other => panic!("`{attempt}` produced {other:?}"),
}
}
}
#[test]
fn a_runaway_script_is_stopped_rather_than_hanging_the_process() {
let request = with_pre_request("let n = 0; while true { n += 1; }");
let err = run_pre(&request).expect_err("an infinite loop must be stopped");
assert!(matches!(err, SendraError::ScriptFailed { .. }), "{err:?}");
}
#[test]
fn a_script_that_prints_nothing_produces_no_output() {
let request = with_pre_request(r#"request.headers["X"] = "y";"#);
let (result, output) = pre(&request).unwrap();
assert!(result.is_ok());
assert!(output.is_empty());
assert_eq!(output.lines(), &[] as &[String]);
}
#[test]
fn print_is_collected_one_line_at_a_time_in_order() {
let request = with_pre_request(
"print(\"first\");\nprint(\"second \" + 40 + 2);\nprint(request.method);",
);
let (result, output) = pre(&request).unwrap();
assert!(result.is_ok());
assert_eq!(output.lines(), ["first", "second 402", "POST"]);
}
#[test]
fn debug_carries_its_position_the_way_rhai_renders_it() {
let request = with_pre_request(r#"debug("looking");"#);
let (_, output) = pre(&request).unwrap();
assert_eq!(output.lines(), [r#"1:1: "looking""#]);
}
#[test]
fn output_comes_back_even_when_the_script_throws() {
let request = with_pre_request("print(\"about to give up\");\nthrow \"no key\";");
let (result, output) = pre(&request).unwrap();
assert!(result.is_err(), "the script threw");
assert_eq!(output.lines(), ["about to give up"]);
}
#[test]
fn a_post_request_script_reports_what_it_printed_alongside_its_verdict() {
let request = with_post_request(
"print(\"status was \" + response.status);\nthrow \"not good enough\";",
);
let (outcome, output) = post(&request, &response());
assert_eq!(
outcome,
ScriptOutcome::Failed {
message: "not good enough".to_string()
}
);
assert_eq!(output.lines(), ["status was 201"]);
}
#[test]
fn one_script_never_sees_another_script_s_output() {
let noisy = with_pre_request(r#"print("from the first");"#);
let (_, first) = pre(&noisy).unwrap();
assert_eq!(first.lines(), ["from the first"]);
let quiet = with_pre_request(r#"request.url = request.url;"#);
let (_, second) = pre(&quiet).unwrap();
assert!(
second.is_empty(),
"the second script printed nothing, but got {:?}",
second.lines()
);
let noisy_again = with_pre_request(r#"print("from the third");"#);
let (_, third) = pre(&noisy_again).unwrap();
assert_eq!(third.lines(), ["from the third"]);
}
}