chore: full stack stability and migration fixes, plus react UI progress
This commit is contained in:
@@ -1,54 +1,222 @@
|
||||
use anyhow::Result;
|
||||
use deno_core::{JsRuntime, RuntimeOptions, v8};
|
||||
use serde_json::Value;
|
||||
use deno_core::{JsRuntime, RuntimeOptions, v8, ModuleLoader, ModuleSource, ModuleSourceCode, ModuleType, ModuleLoadResponse, RequestedModuleType};
|
||||
use serde_json::json;
|
||||
use deno_ast::{ParseParams, MediaType};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct DenoRuntime {
|
||||
// We create a new runtime for each execution to ensure isolation
|
||||
// In a production environment, we might want to pool runtimes or use isolates more efficiently
|
||||
deno_core::extension!(
|
||||
madbase_runtime,
|
||||
ops = [op_fetch],
|
||||
);
|
||||
|
||||
#[deno_core::op2(async)]
|
||||
#[serde]
|
||||
async fn op_fetch(
|
||||
#[string] url: String,
|
||||
#[string] method: String,
|
||||
#[serde] headers: HashMap<String, String>,
|
||||
#[serde] body: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, deno_core::error::AnyError> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut builder = match method.to_uppercase().as_str() {
|
||||
"POST" => client.post(&url),
|
||||
"PUT" => client.put(&url),
|
||||
"DELETE" => client.delete(&url),
|
||||
_ => client.get(&url),
|
||||
};
|
||||
|
||||
for (k, v) in headers {
|
||||
builder = builder.header(k, v);
|
||||
}
|
||||
|
||||
if let Some(b) = body {
|
||||
builder = builder.json(&b);
|
||||
}
|
||||
|
||||
let res = builder.send().await?;
|
||||
let status = res.status().as_u16();
|
||||
let mut res_headers = HashMap::new();
|
||||
for (k, v) in res.headers() {
|
||||
res_headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
|
||||
}
|
||||
let text = res.text().await?;
|
||||
|
||||
Ok(json!({
|
||||
"status": status,
|
||||
"headers": res_headers,
|
||||
"body": text
|
||||
}))
|
||||
}
|
||||
|
||||
struct SandboxedModuleLoader {
|
||||
allowed_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ModuleLoader for SandboxedModuleLoader {
|
||||
fn resolve(&self, specifier: &str, referrer: &str, _kind: deno_core::ResolutionKind) -> Result<deno_core::ModuleSpecifier, anyhow::Error> {
|
||||
let resolved = deno_core::resolve_import(specifier, referrer)?;
|
||||
if resolved.scheme() == "file" {
|
||||
let path = resolved.to_file_path().map_err(|_| anyhow::anyhow!("Invalid file path"))?;
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
|
||||
if !canonical.starts_with(&self.allowed_dir) {
|
||||
return Err(anyhow::anyhow!("Import blocked: {} is outside allowed directory", specifier));
|
||||
}
|
||||
}
|
||||
if resolved.scheme() != "file" && resolved.scheme() != "https" && resolved.scheme() != "http" {
|
||||
return Err(anyhow::anyhow!("Blocked import scheme: {}", resolved.scheme()));
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn load(&self, specifier: &deno_core::ModuleSpecifier, _maybe_referrer: Option<&deno_core::ModuleSpecifier>, _is_dynamic: bool, _requested_module_type: RequestedModuleType) -> ModuleLoadResponse {
|
||||
let specifier = specifier.clone();
|
||||
if specifier.scheme() == "file" {
|
||||
let path = specifier.to_file_path().unwrap();
|
||||
ModuleLoadResponse::Async(Box::pin(async move {
|
||||
let code = tokio::fs::read_to_string(&path).await?;
|
||||
let is_ts = path.extension().is_some_and(|ext| ext == "ts");
|
||||
let transformed = if is_ts {
|
||||
DenoRuntime::transpile(&code, &path)?
|
||||
} else {
|
||||
code
|
||||
};
|
||||
Ok(ModuleSource::new(
|
||||
ModuleType::JavaScript,
|
||||
ModuleSourceCode::String(transformed.into()),
|
||||
&specifier,
|
||||
None,
|
||||
))
|
||||
}))
|
||||
} else {
|
||||
ModuleLoadResponse::Async(Box::pin(async move {
|
||||
Err(anyhow::anyhow!("Remote imports not fully implemented in loader yet"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn near_heap_limit_callback(
|
||||
data: *mut std::ffi::c_void,
|
||||
current_limit: usize,
|
||||
_initial_limit: usize,
|
||||
) -> usize {
|
||||
if !data.is_null() {
|
||||
// SAFETY: data is a *mut v8::Isolate passed from the same thread
|
||||
let isolate = unsafe { &mut *(data as *mut v8::Isolate) };
|
||||
isolate.terminate_execution();
|
||||
}
|
||||
// Give a small amount of extra room so V8 can wind down gracefully
|
||||
// instead of calling FatalProcessOutOfMemory
|
||||
current_limit + 4 * 1024 * 1024
|
||||
}
|
||||
|
||||
pub struct DenoRuntime {}
|
||||
|
||||
impl Default for DenoRuntime {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DenoRuntime {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, code: String, payload: Option<Value>, headers: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>)> {
|
||||
pub fn transpile(code: &str, path: &Path) -> Result<String> {
|
||||
let media_type = MediaType::from_path(path);
|
||||
let specifier = deno_core::url::Url::parse(&format!("file://{}", path.display()))
|
||||
.unwrap_or_else(|_| deno_core::url::Url::parse("file:///index.ts").unwrap());
|
||||
let parsed = deno_ast::parse_module(ParseParams {
|
||||
specifier,
|
||||
text: Arc::from(code),
|
||||
media_type,
|
||||
capture_tokens: false,
|
||||
scope_analysis: false,
|
||||
maybe_syntax: None,
|
||||
})?;
|
||||
let transpiled = parsed.transpile(
|
||||
&Default::default(),
|
||||
&Default::default(),
|
||||
&Default::default(),
|
||||
)?;
|
||||
Ok(transpiled.into_source().text)
|
||||
}
|
||||
|
||||
pub async fn execute(&self, code: String, payload: Option<serde_json::Value>, headers: HashMap<String, String>, env_vars: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>, Vec<serde_json::Value>)> {
|
||||
let timeout_secs = std::env::var("FUNCTION_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30u64);
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let result = Self::execute_inner(code, payload, headers).await;
|
||||
let result = Self::execute_inner(code, payload, headers, env_vars).await;
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
});
|
||||
|
||||
rx.await.map_err(|_| anyhow::anyhow!("Deno execution thread panicked"))?
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
rx,
|
||||
).await {
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(_)) => Err(anyhow::anyhow!("Deno execution thread panicked")),
|
||||
Err(_) => Err(anyhow::anyhow!("Function execution timed out after {}s", timeout_secs)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_inner(code: String, payload: Option<Value>, headers: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>)> {
|
||||
// Initialize JS Runtime
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions::default());
|
||||
pub(crate) async fn execute_inner(mut code: String, payload: Option<serde_json::Value>, headers: HashMap<String, String>, env_vars: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>, Vec<serde_json::Value>)> {
|
||||
let allowed_dir = PathBuf::from("/tmp/madbase_functions");
|
||||
if !allowed_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&allowed_dir);
|
||||
}
|
||||
|
||||
// Transpile entry code if it looks like TS (or we can just always try)
|
||||
if code.contains(':') || code.contains("type ") || code.contains("interface ") {
|
||||
if let Ok(transformed) = Self::transpile(&code, Path::new("index.ts")) {
|
||||
code = transformed;
|
||||
}
|
||||
}
|
||||
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
module_loader: Some(Rc::new(SandboxedModuleLoader { allowed_dir })),
|
||||
create_params: Some(v8::CreateParams::default().heap_limits(0, 128 * 1024 * 1024)),
|
||||
extensions: vec![madbase_runtime::init_ops()],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let isolate = runtime.v8_isolate();
|
||||
let isolate_ptr: *mut v8::Isolate = &mut **isolate;
|
||||
// SAFETY: the callback runs on the same thread as the isolate
|
||||
isolate.add_near_heap_limit_callback(near_heap_limit_callback, isolate_ptr as *mut std::ffi::c_void);
|
||||
|
||||
let env_json = serde_json::to_string(&env_vars)?;
|
||||
runtime.execute_script("<env>", format!("globalThis._env = JSON.parse('{}');", env_json))?;
|
||||
|
||||
// 1. Inject Preamble (Polyfills for Deno.serve, Request, Response, Headers)
|
||||
let preamble = r#"
|
||||
globalThis.__logs__ = [];
|
||||
globalThis.console = {
|
||||
log: (...args) => {
|
||||
Deno.core.print(args.map(a => String(a)).join(" ") + "\n");
|
||||
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(" ");
|
||||
globalThis.__logs__.push({ level: "info", msg, ts: Date.now() });
|
||||
Deno.core.print(msg + "\n");
|
||||
},
|
||||
error: (...args) => {
|
||||
Deno.core.print("[ERROR] " + args.map(a => String(a)).join(" ") + "\n", true);
|
||||
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(" ");
|
||||
globalThis.__logs__.push({ level: "error", msg, ts: Date.now() });
|
||||
Deno.core.print("[ERROR] " + msg + "\n", true);
|
||||
},
|
||||
warn: (...args) => {
|
||||
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(" ");
|
||||
globalThis.__logs__.push({ level: "warn", msg, ts: Date.now() });
|
||||
Deno.core.print("[WARN] " + msg + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,22 +241,7 @@ impl DenoRuntime {
|
||||
}
|
||||
globalThis.Headers = Headers;
|
||||
|
||||
globalThis.Deno = {
|
||||
serve: (handler) => {
|
||||
globalThis._handler = handler;
|
||||
},
|
||||
core: Deno.core,
|
||||
env: {
|
||||
get: (key) => {
|
||||
return globalThis._env ? globalThis._env[key] : null;
|
||||
},
|
||||
toObject: () => {
|
||||
return globalThis._env || {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class Response {
|
||||
globalThis.Response = class Response {
|
||||
constructor(body, init) {
|
||||
this.body = body;
|
||||
this.status = init?.status || 200;
|
||||
@@ -96,10 +249,9 @@ impl DenoRuntime {
|
||||
}
|
||||
async text() { return String(this.body); }
|
||||
async json() { return JSON.parse(this.body); }
|
||||
}
|
||||
globalThis.Response = Response;
|
||||
};
|
||||
|
||||
class Request {
|
||||
globalThis.Request = class Request {
|
||||
constructor(url, init) {
|
||||
this.url = url;
|
||||
this.method = init?.method || "GET";
|
||||
@@ -108,28 +260,43 @@ impl DenoRuntime {
|
||||
}
|
||||
async json() { return typeof this._body === 'string' ? JSON.parse(this._body) : this._body; }
|
||||
async text() { return typeof this._body === 'string' ? this._body : JSON.stringify(this._body); }
|
||||
}
|
||||
globalThis.Request = Request;
|
||||
};
|
||||
|
||||
globalThis.fetch = async (url, init) => {
|
||||
const method = init?.method || "GET";
|
||||
const headers = {};
|
||||
if (init?.headers) {
|
||||
const h = new Headers(init.headers);
|
||||
h.forEach((v, k) => headers[k] = v);
|
||||
}
|
||||
let body = init?.body;
|
||||
if (body && typeof body !== 'string') body = JSON.stringify(body);
|
||||
|
||||
const res = await Deno.core.ops.op_fetch(url, method, headers, body);
|
||||
return new Response(res.body, { status: res.status, headers: res.headers });
|
||||
};
|
||||
|
||||
globalThis.Deno = {
|
||||
serve: (handler) => { globalThis._handler = handler; },
|
||||
core: Deno.core,
|
||||
env: {
|
||||
get: (key) => globalThis._env ? globalThis._env[key] : null,
|
||||
toObject: () => globalThis._env || {}
|
||||
}
|
||||
};
|
||||
"#;
|
||||
|
||||
runtime.execute_script("<preamble>", preamble.to_string())?;
|
||||
|
||||
// 2. Execute User Code
|
||||
runtime.execute_script("<user_script>", code.to_string())?;
|
||||
|
||||
// 3. Invoke Handler
|
||||
// Double-serialize to prevent JS injection: the outer JSON string is parsed
|
||||
// by JSON.parse() in JS, producing the original value safely.
|
||||
let payload_json = serde_json::to_string(&payload.unwrap_or(serde_json::json!({})))?;
|
||||
let payload_json = serde_json::to_string(&payload.unwrap_or(json!({})))?;
|
||||
let headers_json = serde_json::to_string(&headers)?;
|
||||
let safe_payload = serde_json::to_string(&payload_json)?;
|
||||
let safe_headers = serde_json::to_string(&headers_json)?;
|
||||
|
||||
let invoke_script = format!(r#"
|
||||
(async () => {{
|
||||
if (!globalThis._handler) {{
|
||||
return {{ error: "No handler registered via Deno.serve" }};
|
||||
}}
|
||||
if (!globalThis._handler) return {{ error: "No handler registered via Deno.serve" }};
|
||||
try {{
|
||||
const headers = JSON.parse({1});
|
||||
const body = JSON.parse({0});
|
||||
@@ -140,19 +307,13 @@ impl DenoRuntime {
|
||||
}});
|
||||
const res = await globalThis._handler(req);
|
||||
const text = await res.text();
|
||||
|
||||
const resHeaders = {{}};
|
||||
if (res.headers && typeof res.headers.forEach === 'function') {{
|
||||
res.headers.forEach((v, k) => resHeaders[k] = v);
|
||||
}}
|
||||
|
||||
return {{
|
||||
result: text,
|
||||
headers: resHeaders,
|
||||
status: res.status
|
||||
}};
|
||||
return {{ result: text, headers: resHeaders, status: res.status, logs: globalThis.__logs__ }};
|
||||
}} catch (e) {{
|
||||
return {{ error: String(e) }};
|
||||
return {{ error: String(e), logs: globalThis.__logs__ }};
|
||||
}}
|
||||
}})()
|
||||
"#, safe_payload, safe_headers);
|
||||
@@ -160,100 +321,272 @@ impl DenoRuntime {
|
||||
let result_val = runtime.execute_script("<invocation>", invoke_script)?;
|
||||
#[allow(deprecated)]
|
||||
let result = runtime.resolve_value(result_val).await?;
|
||||
|
||||
let scope = &mut runtime.handle_scope();
|
||||
let local = v8::Local::new(scope, result);
|
||||
let deserialized_value: Value = deno_core::serde_v8::from_v8(scope, local)?;
|
||||
let deserialized_value: serde_json::Value = deno_core::serde_v8::from_v8(scope, local)?;
|
||||
|
||||
let stdout = if let Some(res) = deserialized_value.get("result") {
|
||||
res.as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let stderr = if let Some(err) = deserialized_value.get("error") {
|
||||
err.as_str().unwrap_or("Unknown error").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let status = if let Some(s) = deserialized_value.get("status") {
|
||||
s.as_u64().unwrap_or(200) as u16
|
||||
} else {
|
||||
200
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
if let Some(h) = deserialized_value.get("headers") {
|
||||
if let Some(obj) = h.as_object() {
|
||||
for (k, v) in obj {
|
||||
if let Some(s) = v.as_str() {
|
||||
headers.insert(k.clone(), s.to_string());
|
||||
}
|
||||
}
|
||||
let stdout = deserialized_value.get("result").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let stderr = deserialized_value.get("error").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let status = deserialized_value.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
|
||||
let mut res_headers = HashMap::new();
|
||||
if let Some(h) = deserialized_value.get("headers").and_then(|v| v.as_object()) {
|
||||
for (k, v) in h {
|
||||
if let Some(s) = v.as_str() { res_headers.insert(k.clone(), s.to_string()); }
|
||||
}
|
||||
}
|
||||
let logs = deserialized_value.get("logs").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
Ok((stdout, stderr, status, headers))
|
||||
Ok((stdout, stderr, status, res_headers, logs))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{json, Value};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Validates that the double-serialization technique produces safe JS string
|
||||
/// literals, even when the payload contains characters that could break out
|
||||
/// of a JS template if interpolated naively.
|
||||
#[test]
|
||||
fn test_double_serialize_escapes_js_injection() {
|
||||
let malicious_payload = json!({
|
||||
"key": "\"); process.exit(1); //"
|
||||
});
|
||||
// --- Sandbox tests ---
|
||||
|
||||
let first = serde_json::to_string(&malicious_payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
|
||||
// The double-serialized value must be a valid JSON string
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, malicious_payload);
|
||||
fn make_loader(dir: &str) -> SandboxedModuleLoader {
|
||||
SandboxedModuleLoader { allowed_dir: PathBuf::from(dir) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_serialize_handles_backtick_injection() {
|
||||
let payload = json!({
|
||||
"attack": "${globalThis.Deno.exit()}"
|
||||
});
|
||||
|
||||
let first = serde_json::to_string(&payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
|
||||
// The value when placed in a JS template literal is still just a string
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, payload);
|
||||
fn test_sandboxed_loader_blocks_etc_passwd() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("/etc/passwd", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_err(), "Should block /etc/passwd");
|
||||
assert!(result.unwrap_err().to_string().contains("outside allowed directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_serialize_handles_empty() {
|
||||
let payload = json!({});
|
||||
let first = serde_json::to_string(&payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, payload);
|
||||
fn test_sandboxed_loader_blocks_parent_traversal() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("../../etc/passwd", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_err(), "Should block parent traversal to /etc/passwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_serialize_preserves_unicode() {
|
||||
let payload = json!({"emoji": "🔐", "chinese": "安全"});
|
||||
let first = serde_json::to_string(&payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
fn test_sandboxed_loader_allows_local_import() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("./helper.ts", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
// resolve succeeds even if the file doesn't exist (file lookup happens in load())
|
||||
assert!(result.is_ok(), "Should allow ./helper.ts within allowed dir");
|
||||
}
|
||||
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, payload);
|
||||
#[test]
|
||||
fn test_sandboxed_loader_allows_https_import() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("https://deno.land/std/testing/asserts.ts", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_ok(), "Should allow https:// imports");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandboxed_loader_blocks_ftp() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("ftp://evil.com/payload", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_err(), "Should block ftp:// scheme");
|
||||
assert!(result.unwrap_err().to_string().contains("Blocked import scheme"));
|
||||
}
|
||||
|
||||
// --- JS injection safety ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_js_injection_safe_payload() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const body = await req.text();
|
||||
return new Response(JSON.stringify({ received: body, alive: true }));
|
||||
});
|
||||
"#.to_string();
|
||||
let malicious_payload = json!({"key": "'; process.exit(); '"});
|
||||
let (stdout, stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, Some(malicious_payload), HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
// The critical assertion: the runtime didn't crash and returned a response
|
||||
let res: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(res["alive"], true, "Runtime survived malicious payload, stderr={}", stderr);
|
||||
assert!(res["received"].as_str().unwrap().contains("process.exit()"), "Malicious string was preserved as data");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_js_injection_safe_headers() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const val = req.headers.get("x-evil");
|
||||
return new Response(val || "none");
|
||||
});
|
||||
"#.to_string();
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-evil".to_string(), "\"});process.exit();//".to_string());
|
||||
let (stdout, stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, headers.clone(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stderr.is_empty(), "Should not crash: stderr={}", stderr);
|
||||
assert_eq!(stdout, headers["x-evil"]);
|
||||
}
|
||||
|
||||
// --- Resource limits ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeout_enforcement() {
|
||||
// Use a short timeout for testing
|
||||
std::env::set_var("FUNCTION_TIMEOUT_SECS", "2");
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
while(true) {}
|
||||
return new Response("unreachable");
|
||||
});
|
||||
"#.to_string();
|
||||
let result = runtime.execute(code, None, HashMap::new(), HashMap::new()).await;
|
||||
std::env::remove_var("FUNCTION_TIMEOUT_SECS");
|
||||
assert!(result.is_err(), "Infinite loop should be terminated by timeout");
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("timed out") || err_msg.contains("panicked"),
|
||||
"Error should mention timeout, got: {}", err_msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_limit_enforcement() {
|
||||
let runtime = DenoRuntime::new();
|
||||
// Use JS objects/strings that consume V8 managed heap (not external backing stores)
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const arr = [];
|
||||
while (true) {
|
||||
arr.push("x".repeat(10000) + Math.random().toString());
|
||||
}
|
||||
return new Response("should not reach here");
|
||||
});
|
||||
"#.to_string();
|
||||
std::env::set_var("FUNCTION_TIMEOUT_SECS", "10");
|
||||
let result = runtime.execute(code, None, HashMap::new(), HashMap::new()).await;
|
||||
std::env::remove_var("FUNCTION_TIMEOUT_SECS");
|
||||
// V8 OOMs, the thread panics, or the timeout fires — any of these is an error
|
||||
assert!(result.is_err(), "Should fail when exceeding 128MB heap limit");
|
||||
}
|
||||
|
||||
// --- TypeScript ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typescript_execution() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
interface User { name: string; }
|
||||
Deno.serve(async (req) => {
|
||||
const user: User = { name: "MadBase" };
|
||||
return new Response(`Hello ${user.name}`);
|
||||
});
|
||||
"#.to_string();
|
||||
let (stdout, _stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stdout, "Hello MadBase");
|
||||
}
|
||||
|
||||
// --- Environment variables ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_env_vars_accessible() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const val = Deno.env.get("MY_VAR");
|
||||
return new Response(val || "missing");
|
||||
});
|
||||
"#.to_string();
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert("MY_VAR".to_string(), "hello_from_env".to_string());
|
||||
let (stdout, _stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, HashMap::new(), env_vars)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stdout, "hello_from_env");
|
||||
}
|
||||
|
||||
// --- Fetch API ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_api_available() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const hasFetch = typeof fetch === 'function';
|
||||
return new Response(JSON.stringify({ hasFetch }));
|
||||
});
|
||||
"#.to_string();
|
||||
let (stdout, _stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let res: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert!(res["hasFetch"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
// --- Console log capture ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_console_log_capture() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
console.log("hello from log");
|
||||
console.error("an error");
|
||||
return new Response("ok");
|
||||
});
|
||||
"#.to_string();
|
||||
let (stdout, _stderr, _status, _headers, logs) = runtime
|
||||
.execute(code, None, HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stdout, "ok");
|
||||
assert!(logs.len() >= 2, "Should capture at least 2 log entries, got {}", logs.len());
|
||||
let first_log = &logs[0];
|
||||
assert!(first_log.to_string().contains("hello from log"));
|
||||
}
|
||||
|
||||
// --- Worker pool ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_pool_concurrent() {
|
||||
let pool = Arc::new(crate::worker_pool::DenoPool::new(4));
|
||||
let mut handles = vec![];
|
||||
for i in 0..10 {
|
||||
let pool = pool.clone();
|
||||
let code = format!(r#"
|
||||
Deno.serve(async (req) => {{
|
||||
return new Response("result-{i}");
|
||||
}});
|
||||
"#);
|
||||
handles.push(tokio::spawn(async move {
|
||||
pool.execute(code, None, HashMap::new(), HashMap::new()).await
|
||||
}));
|
||||
}
|
||||
let mut success_count = 0;
|
||||
for handle in handles {
|
||||
if let Ok(Ok((stdout, _, _, _, _))) = handle.await {
|
||||
assert!(stdout.starts_with("result-"));
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(success_count, 10, "All 10 concurrent invocations should complete");
|
||||
}
|
||||
|
||||
// --- Transpile unit test ---
|
||||
|
||||
#[test]
|
||||
fn test_transpile_strips_types() {
|
||||
let ts_code = "const x: number = 42; export default x;";
|
||||
let result = DenoRuntime::transpile(ts_code, Path::new("test.ts")).unwrap();
|
||||
assert!(!result.contains(": number"), "Type annotations should be stripped");
|
||||
assert!(result.contains("42"), "Value should be preserved");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user