diff --git a/Cargo.toml b/Cargo.toml index b02d17d..27d41f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ regex = "1" # Deno runtime deno_core = "0.394" +deno_error = "0.7" url = "2" # Dev/Test @@ -56,3 +57,4 @@ pretty_assertions = "1" rstest = "0.23" wiremock = "0.6" tokio-stream = "0.1" +tempfile = "3" diff --git a/wfe-yaml/Cargo.toml b/wfe-yaml/Cargo.toml index d126034..eaa5a8f 100644 --- a/wfe-yaml/Cargo.toml +++ b/wfe-yaml/Cargo.toml @@ -6,7 +6,7 @@ description = "YAML workflow definitions for WFE" [features] default = [] -deno = ["deno_core", "url"] +deno = ["deno_core", "deno_error", "url", "reqwest"] [dependencies] wfe-core = { workspace = true } @@ -19,7 +19,9 @@ thiserror = { workspace = true } tracing = { workspace = true } regex = { workspace = true } deno_core = { workspace = true, optional = true } +deno_error = { workspace = true, optional = true } url = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } [dev-dependencies] pretty_assertions = { workspace = true } @@ -27,3 +29,5 @@ tokio = { workspace = true, features = ["test-util", "process"] } tokio-util = "0.7" wfe-core = { workspace = true, features = ["test-support"] } wfe = { path = "../wfe" } +wiremock = { workspace = true } +tempfile = { workspace = true } diff --git a/wfe-yaml/src/executors/deno/js/bootstrap.js b/wfe-yaml/src/executors/deno/js/bootstrap.js index 0fdd7ae..739b46f 100644 --- a/wfe-yaml/src/executors/deno/js/bootstrap.js +++ b/wfe-yaml/src/executors/deno/js/bootstrap.js @@ -1,3 +1,14 @@ globalThis.inputs = () => Deno.core.ops.op_inputs(); globalThis.output = (key, value) => Deno.core.ops.op_output(key, value); globalThis.log = (msg) => Deno.core.ops.op_log(msg); + +globalThis.fetch = async (url, options) => { + const resp = await Deno.core.ops.op_fetch(url, options || null); + return { + status: resp.status, + ok: resp.ok, + headers: resp.headers, + text: () => Promise.resolve(resp.body), + json: () => Promise.resolve(JSON.parse(resp.body)), + }; +}; diff --git a/wfe-yaml/src/executors/deno/mod.rs b/wfe-yaml/src/executors/deno/mod.rs index eccb3f5..3f37763 100644 --- a/wfe-yaml/src/executors/deno/mod.rs +++ b/wfe-yaml/src/executors/deno/mod.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod module_loader; pub mod ops; pub mod permissions; pub mod runtime; diff --git a/wfe-yaml/src/executors/deno/module_loader.rs b/wfe-yaml/src/executors/deno/module_loader.rs new file mode 100644 index 0000000..523bf34 --- /dev/null +++ b/wfe-yaml/src/executors/deno/module_loader.rs @@ -0,0 +1,391 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use deno_core::{ + ModuleLoadOptions, ModuleLoadReferrer, ModuleLoadResponse, ModuleLoader, ModuleSource, + ModuleSourceCode, ModuleSpecifier, ModuleType, ResolutionKind, +}; +use deno_error::JsErrorBox; + +use super::permissions::PermissionChecker; + +/// Custom module loader that enforces permissions and rewrites npm: specifiers. +pub struct WfeModuleLoader { + permissions: Rc>, +} + +impl WfeModuleLoader { + pub fn new(permissions: Rc>) -> Self { + Self { permissions } + } + + /// Rewrite `npm:package@version` to `https://esm.sh/package@version`. + pub fn resolve_npm_specifier(specifier: &str) -> Option { + specifier + .strip_prefix("npm:") + .map(|rest| format!("https://esm.sh/{rest}")) + } +} + +impl ModuleLoader for WfeModuleLoader { + fn resolve( + &self, + specifier: &str, + referrer: &str, + kind: ResolutionKind, + ) -> Result { + // Block dynamic imports if not allowed. + if matches!(kind, ResolutionKind::DynamicImport) { + let checker = self.permissions.borrow(); + if !checker.allow_dynamic_import { + return Err(JsErrorBox::generic( + "Dynamic import is not allowed by permissions", + )); + } + } + + // ext: URLs are handled by deno_core. + if specifier.starts_with("ext:") { + return ModuleSpecifier::parse(specifier) + .map_err(|e| JsErrorBox::generic(format!("Invalid ext URL: {e}"))); + } + + // npm: specifier -> rewrite to esm.sh URL. + if let Some(esm_url) = Self::resolve_npm_specifier(specifier) { + let parsed = ModuleSpecifier::parse(&esm_url) + .map_err(|e| JsErrorBox::generic(format!("Invalid esm.sh URL: {e}")))?; + return Ok(parsed); + } + + // wfe: scheme for inline modules (used by load_main_es_module_from_code). + if specifier.starts_with("wfe:") { + return ModuleSpecifier::parse(specifier) + .map_err(|e| JsErrorBox::generic(format!("Invalid wfe URL: {e}"))); + } + + // Absolute file:// URL. + if specifier.starts_with("file://") { + let parsed = ModuleSpecifier::parse(specifier) + .map_err(|e| JsErrorBox::generic(format!("Invalid file URL: {e}")))?; + let path = parsed + .to_file_path() + .map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?; + let checker = self.permissions.borrow(); + checker + .check_read( + path.to_str() + .ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?, + ) + .map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?; + return Ok(parsed); + } + + // Absolute https:// URL. + if specifier.starts_with("https://") || specifier.starts_with("http://") { + let parsed = ModuleSpecifier::parse(specifier) + .map_err(|e| JsErrorBox::generic(format!("Invalid URL: {e}")))?; + let host = parsed + .host_str() + .ok_or_else(|| JsErrorBox::generic("URL has no host"))?; + let checker = self.permissions.borrow(); + checker + .check_net(host) + .map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?; + return Ok(parsed); + } + + // Relative or bare path — resolve against referrer. + // This handles ./foo, ../foo, and /foo (absolute path on same origin, e.g. esm.sh redirects) + if specifier.starts_with("./") + || specifier.starts_with("../") + || specifier.starts_with('/') + { + let base = ModuleSpecifier::parse(referrer) + .map_err(|e| JsErrorBox::generic(format!("Invalid referrer '{referrer}': {e}")))?; + let resolved = base + .join(specifier) + .map_err(|e| JsErrorBox::generic(format!("Failed to resolve '{specifier}': {e}")))?; + + // Check permissions based on scheme. + match resolved.scheme() { + "file" => { + let path = resolved + .to_file_path() + .map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?; + let checker = self.permissions.borrow(); + checker + .check_read( + path.to_str() + .ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?, + ) + .map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?; + } + "https" | "http" => { + let host = resolved + .host_str() + .ok_or_else(|| JsErrorBox::generic("URL has no host"))?; + let checker = self.permissions.borrow(); + checker + .check_net(host) + .map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?; + } + _ => {} + } + + return Ok(resolved); + } + + Err(JsErrorBox::generic(format!( + "Unsupported module specifier: '{specifier}'" + ))) + } + + fn load( + &self, + module_specifier: &ModuleSpecifier, + _maybe_referrer: Option<&ModuleLoadReferrer>, + _options: ModuleLoadOptions, + ) -> ModuleLoadResponse { + let specifier = module_specifier.clone(); + let permissions = self.permissions.clone(); + + match specifier.scheme() { + "ext" => { + // Handled by deno_core; should not reach here. + ModuleLoadResponse::Sync(Err(JsErrorBox::generic( + "ext: modules are handled by deno_core", + ))) + } + "https" | "http" => { + // Fetch over network. + let url = specifier.to_string(); + ModuleLoadResponse::Async(Box::pin(async move { + // Permission check on the host. + let host = specifier + .host_str() + .ok_or_else(|| JsErrorBox::generic("URL has no host"))? + .to_string(); + { + let checker = permissions.borrow(); + checker + .check_net(&host) + .map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?; + } + + let response = reqwest::get(&url) + .await + .map_err(|e| { + JsErrorBox::generic(format!("Failed to fetch module '{url}': {e}")) + })?; + + if !response.status().is_success() { + return Err(JsErrorBox::generic(format!( + "Failed to fetch module '{url}': HTTP {}", + response.status() + ))); + } + + let code: String = response.text().await.map_err(|e| { + JsErrorBox::generic(format!("Failed to read module body '{url}': {e}")) + })?; + + Ok(ModuleSource::new( + ModuleType::JavaScript, + ModuleSourceCode::String(code.into()), + &specifier, + None, + )) + })) + } + "file" => { + // Read from disk. + let path_result = specifier + .to_file_path() + .map_err(|_| JsErrorBox::generic("Cannot convert URL to file path")); + match path_result { + Ok(path) => { + let path_str = path.to_string_lossy().to_string(); + + // Permission check. + { + let checker = permissions.borrow(); + if let Err(e) = checker.check_read(&path_str) { + return ModuleLoadResponse::Sync(Err(JsErrorBox::new( + "PermissionError", + e.to_string(), + ))); + } + } + + match std::fs::read_to_string(&path) { + Ok(code) => ModuleLoadResponse::Sync(Ok(ModuleSource::new( + ModuleType::JavaScript, + ModuleSourceCode::String(code.into()), + &specifier, + None, + ))), + Err(e) => ModuleLoadResponse::Sync(Err(JsErrorBox::generic( + format!("Failed to read module '{}': {e}", path.display()), + ))), + } + } + Err(e) => ModuleLoadResponse::Sync(Err(e)), + } + } + scheme => ModuleLoadResponse::Sync(Err(JsErrorBox::generic(format!( + "Unsupported module scheme: '{scheme}'" + )))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::executors::deno::config::DenoPermissions; + + fn make_loader(perms: DenoPermissions) -> WfeModuleLoader { + let checker = PermissionChecker::from_config(&perms); + WfeModuleLoader::new(Rc::new(RefCell::new(checker))) + } + + #[test] + fn resolve_npm_to_esm_sh() { + let result = WfeModuleLoader::resolve_npm_specifier("npm:lodash@4.17.21"); + assert_eq!(result, Some("https://esm.sh/lodash@4.17.21".to_string())); + } + + #[test] + fn resolve_npm_scoped_package() { + let result = WfeModuleLoader::resolve_npm_specifier("npm:@org/pkg@1.0.0"); + assert_eq!(result, Some("https://esm.sh/@org/pkg@1.0.0".to_string())); + } + + #[test] + fn resolve_non_npm_returns_none() { + assert!(WfeModuleLoader::resolve_npm_specifier("https://example.com/mod.js").is_none()); + assert!(WfeModuleLoader::resolve_npm_specifier("./local.js").is_none()); + } + + #[test] + fn resolve_npm_specifier_via_loader() { + let loader = make_loader(DenoPermissions { + net: vec!["esm.sh".to_string()], + ..Default::default() + }); + let result = loader + .resolve("npm:lodash@4", "ext:wfe/bootstrap.js", ResolutionKind::Import) + .unwrap(); + assert_eq!(result.as_str(), "https://esm.sh/lodash@4"); + } + + #[test] + fn resolve_https_with_permission() { + let loader = make_loader(DenoPermissions { + net: vec!["cdn.example.com".to_string()], + ..Default::default() + }); + let result = loader + .resolve( + "https://cdn.example.com/mod.js", + "ext:wfe/bootstrap.js", + ResolutionKind::Import, + ) + .unwrap(); + assert_eq!(result.as_str(), "https://cdn.example.com/mod.js"); + } + + #[test] + fn resolve_https_without_permission_fails() { + let loader = make_loader(DenoPermissions::default()); + let result = loader.resolve( + "https://evil.com/mod.js", + "ext:wfe/bootstrap.js", + ResolutionKind::Import, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Permission denied")); + } + + #[test] + fn resolve_dynamic_import_denied() { + let loader = make_loader(DenoPermissions { + net: vec!["cdn.example.com".to_string()], + dynamic_import: false, + ..Default::default() + }); + let result = loader.resolve( + "https://cdn.example.com/mod.js", + "ext:wfe/bootstrap.js", + ResolutionKind::DynamicImport, + ); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Dynamic import is not allowed")); + } + + #[test] + fn resolve_dynamic_import_allowed() { + let loader = make_loader(DenoPermissions { + net: vec!["cdn.example.com".to_string()], + dynamic_import: true, + ..Default::default() + }); + let result = loader.resolve( + "https://cdn.example.com/mod.js", + "ext:wfe/bootstrap.js", + ResolutionKind::DynamicImport, + ); + assert!(result.is_ok()); + } + + #[test] + fn resolve_ext_url() { + let loader = make_loader(DenoPermissions::default()); + let result = loader + .resolve( + "ext:wfe/bootstrap.js", + "ext:wfe/bootstrap.js", + ResolutionKind::Import, + ) + .unwrap(); + assert_eq!(result.as_str(), "ext:wfe/bootstrap.js"); + } + + #[test] + fn resolve_relative_from_file_referrer() { + let loader = make_loader(DenoPermissions { + read: vec!["/tmp".to_string()], + ..Default::default() + }); + let result = loader + .resolve( + "./helper.js", + "file:///tmp/main.js", + ResolutionKind::Import, + ) + .unwrap(); + assert_eq!(result.as_str(), "file:///tmp/helper.js"); + } + + #[test] + fn resolve_relative_without_read_permission_fails() { + let loader = make_loader(DenoPermissions::default()); + let result = loader.resolve("./helper.js", "file:///tmp/main.js", ResolutionKind::Import); + assert!(result.is_err()); + } + + #[test] + fn resolve_unsupported_specifier() { + let loader = make_loader(DenoPermissions::default()); + let result = loader.resolve( + "ftp://bad.com/mod.js", + "ext:wfe/bootstrap.js", + ResolutionKind::Import, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unsupported")); + } +} diff --git a/wfe-yaml/src/executors/deno/ops/http.rs b/wfe-yaml/src/executors/deno/ops/http.rs new file mode 100644 index 0000000..ec7b9e2 --- /dev/null +++ b/wfe-yaml/src/executors/deno/ops/http.rs @@ -0,0 +1,108 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use deno_core::op2; +use deno_core::OpState; +use deno_error::JsErrorBox; +use serde::{Deserialize, Serialize}; + +use crate::executors::deno::permissions::PermissionChecker; + +/// Options for the fetch call (method, headers, body). +#[derive(Deserialize, Default)] +pub struct FetchOptions { + pub method: Option, + pub headers: Option>, + pub body: Option, +} + +/// Response returned to JavaScript from fetch. +#[derive(Serialize)] +pub struct FetchResponse { + pub status: u16, + pub ok: bool, + pub headers: HashMap, + pub body: String, +} + +#[op2] +#[serde] +pub async fn op_fetch( + state: Rc>, + #[string] url: String, + #[serde] options: Option, +) -> Result { + // 1. Parse URL to extract host for permission check. + let parsed = url::Url::parse(&url) + .map_err(|e| JsErrorBox::generic(format!("Invalid URL '{url}': {e}")))?; + + let host = parsed + .host_str() + .ok_or_else(|| JsErrorBox::generic(format!("URL '{url}' has no host")))? + .to_string(); + + // 2. Check net permission. + { + let state = state.borrow(); + let checker = state.borrow::(); + checker + .check_net(&host) + .map_err(|e| JsErrorBox::generic(e.to_string()))?; + } + + // 3. Build the request. + let opts = options.unwrap_or_default(); + let method = opts.method.as_deref().unwrap_or("GET"); + + let client = reqwest::Client::new(); + let mut builder = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + "HEAD" => client.head(&url), + other => { + return Err(JsErrorBox::generic(format!( + "Unsupported HTTP method: {other}" + ))); + } + }; + + if let Some(ref headers) = opts.headers { + for (key, value) in headers { + builder = builder.header(key.as_str(), value.as_str()); + } + } + + if let Some(ref body) = opts.body { + builder = builder.body(body.clone()); + } + + // 4. Execute request. + let response = builder + .send() + .await + .map_err(|e| JsErrorBox::generic(format!("Fetch failed for '{url}': {e}")))?; + + // 5. Build response. + let status = response.status().as_u16(); + let ok = response.status().is_success(); + let headers: HashMap = response + .headers() + .iter() + .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + let body = response + .text() + .await + .map_err(|e| JsErrorBox::generic(format!("Failed to read response body: {e}")))?; + + Ok(FetchResponse { + status, + ok, + headers, + body, + }) +} diff --git a/wfe-yaml/src/executors/deno/ops/mod.rs b/wfe-yaml/src/executors/deno/ops/mod.rs index 6ee0278..1bc7dc9 100644 --- a/wfe-yaml/src/executors/deno/ops/mod.rs +++ b/wfe-yaml/src/executors/deno/ops/mod.rs @@ -1,3 +1,4 @@ +pub mod http; pub mod workflow; pub use workflow::{StepMeta, StepOutputs, WorkflowInputs}; diff --git a/wfe-yaml/src/executors/deno/ops/workflow.rs b/wfe-yaml/src/executors/deno/ops/workflow.rs index f363e78..b3c05bc 100644 --- a/wfe-yaml/src/executors/deno/ops/workflow.rs +++ b/wfe-yaml/src/executors/deno/ops/workflow.rs @@ -46,7 +46,7 @@ pub fn op_log(state: &mut OpState, #[string] msg: String) { deno_core::extension!( wfe_ops, - ops = [op_inputs, op_output, op_log], + ops = [op_inputs, op_output, op_log, super::http::op_fetch], esm_entry_point = "ext:wfe/bootstrap.js", esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"], ); diff --git a/wfe-yaml/src/executors/deno/runtime.rs b/wfe-yaml/src/executors/deno/runtime.rs index aa32610..07fa2c7 100644 --- a/wfe-yaml/src/executors/deno/runtime.rs +++ b/wfe-yaml/src/executors/deno/runtime.rs @@ -1,10 +1,13 @@ +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; use deno_core::JsRuntime; use deno_core::RuntimeOptions; use wfe_core::WfeError; use super::config::DenoConfig; +use super::module_loader::WfeModuleLoader; use super::ops::workflow::{wfe_ops, StepMeta, StepOutputs, WorkflowInputs}; use super::permissions::PermissionChecker; @@ -16,8 +19,18 @@ pub fn create_runtime( ) -> Result { let ext = wfe_ops::init(); + // Build permissions, auto-adding esm.sh if modules are declared. + let mut permissions = config.permissions.clone(); + if !config.modules.is_empty() && !permissions.net.iter().any(|h| h == "esm.sh") { + permissions.net.push("esm.sh".to_string()); + } + + let checker = Rc::new(RefCell::new(PermissionChecker::from_config(&permissions))); + let module_loader = WfeModuleLoader::new(checker.clone()); + let runtime = JsRuntime::new(RuntimeOptions { extensions: vec![ext], + module_loader: Some(Rc::new(module_loader)), ..Default::default() }); @@ -34,12 +47,18 @@ pub fn create_runtime( state.put(StepMeta { name: step_name.to_string(), }); - state.put(PermissionChecker::from_config(&config.permissions)); + state.put(PermissionChecker::from_config(&permissions)); } Ok(runtime) } +/// Returns whether esm.sh would be auto-added for the given config. +/// Exposed for testing. +pub fn would_auto_add_esm_sh(config: &DenoConfig) -> bool { + !config.modules.is_empty() && !config.permissions.net.iter().any(|h| h == "esm.sh") +} + #[cfg(test)] mod tests { use super::*; @@ -78,4 +97,46 @@ mod tests { let meta = state.borrow::(); assert_eq!(meta.name, "my-step"); } + + #[test] + fn auto_add_esm_sh_when_modules_declared() { + let config = DenoConfig { + script: Some("1".to_string()), + file: None, + permissions: DenoPermissions::default(), + modules: vec!["npm:lodash@4".to_string()], + env: HashMap::new(), + timeout_ms: None, + }; + assert!(would_auto_add_esm_sh(&config)); + } + + #[test] + fn no_auto_add_esm_sh_when_no_modules() { + let config = DenoConfig { + script: Some("1".to_string()), + file: None, + permissions: DenoPermissions::default(), + modules: vec![], + env: HashMap::new(), + timeout_ms: None, + }; + assert!(!would_auto_add_esm_sh(&config)); + } + + #[test] + fn no_auto_add_esm_sh_when_already_present() { + let config = DenoConfig { + script: Some("1".to_string()), + file: None, + permissions: DenoPermissions { + net: vec!["esm.sh".to_string()], + ..Default::default() + }, + modules: vec!["npm:lodash@4".to_string()], + env: HashMap::new(), + timeout_ms: None, + }; + assert!(!would_auto_add_esm_sh(&config)); + } } diff --git a/wfe-yaml/src/executors/deno/step.rs b/wfe-yaml/src/executors/deno/step.rs index 0300108..d171145 100644 --- a/wfe-yaml/src/executors/deno/step.rs +++ b/wfe-yaml/src/executors/deno/step.rs @@ -51,6 +51,8 @@ impl StepBody for DenoStep { let config = self.config.clone(); let timeout_ms = self.config.timeout_ms; + let use_module = needs_module_evaluation(&source); + let file_path = self.config.file.clone(); // JsRuntime is !Send, so we run it on a dedicated thread with its own // single-threaded tokio runtime. @@ -63,8 +65,19 @@ impl StepBody for DenoStep { })?; rt.block_on(async move { - run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms) + if use_module { + run_module_inner( + &config, + workflow_data, + &step_name, + &source, + file_path.as_deref(), + timeout_ms, + ) .await + } else { + run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms).await + } }) }); @@ -79,6 +92,13 @@ impl StepBody for DenoStep { } } +/// Check if the source code uses ES module syntax or top-level await. +fn needs_module_evaluation(source: &str) -> bool { + // Top-level await requires module evaluation. ES import/export also require it. + source.contains("import ") || source.contains("import(") || source.contains("export ") + || source.contains("await ") +} + async fn run_script_inner( config: &DenoConfig, workflow_data: serde_json::Value, @@ -130,7 +150,100 @@ async fn run_script_inner( } })?; - // Extract outputs from OpState. + extract_outputs(&mut runtime) +} + +async fn run_module_inner( + config: &DenoConfig, + workflow_data: serde_json::Value, + step_name: &str, + source: &str, + file_path: Option<&str>, + timeout_ms: Option, +) -> wfe_core::Result { + let mut runtime = create_runtime(config, workflow_data, step_name)?; + + let _timeout_guard = timeout_ms.map(|ms| { + let isolate_handle = runtime.v8_isolate().thread_safe_handle(); + let duration = std::time::Duration::from_millis(ms); + std::thread::spawn(move || { + std::thread::sleep(duration); + isolate_handle.terminate_execution(); + }) + }); + + // Determine the module URL. Use the file path if available, otherwise a synthetic URL. + let module_url = if let Some(path) = file_path { + let abs = std::path::Path::new(path); + let abs = if abs.is_absolute() { + abs.to_path_buf() + } else { + std::env::current_dir() + .map_err(|e| WfeError::StepExecution(format!("Cannot get cwd: {e}")))? + .join(abs) + }; + url::Url::from_file_path(&abs) + .map_err(|_| { + WfeError::StepExecution(format!("Cannot convert path to URL: {}", abs.display())) + })? + .to_string() + } else { + "wfe:///inline-module.js".to_string() + }; + + let specifier = deno_core::ModuleSpecifier::parse(&module_url).map_err(|e| { + WfeError::StepExecution(format!("Invalid module URL '{module_url}': {e}")) + })?; + + let module_id = runtime + .load_main_es_module_from_code(&specifier, source.to_string()) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("terminated") { + WfeError::StepExecution(format!( + "Deno script timed out after {}ms", + timeout_ms.unwrap_or(0) + )) + } else { + WfeError::StepExecution(format!("Deno module load error: {e}")) + } + })?; + + let eval_future = runtime.mod_evaluate(module_id); + + // Drive the event loop to resolve imports and execute the module. + runtime + .run_event_loop(Default::default()) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("terminated") { + WfeError::StepExecution(format!( + "Deno script timed out after {}ms", + timeout_ms.unwrap_or(0) + )) + } else { + WfeError::StepExecution(format!("Deno event loop error: {e}")) + } + })?; + + eval_future.await.map_err(|e| { + let msg = e.to_string(); + if msg.contains("terminated") { + WfeError::StepExecution(format!( + "Deno script timed out after {}ms", + timeout_ms.unwrap_or(0) + )) + } else { + WfeError::StepExecution(format!("Deno module error: {e}")) + } + })?; + + extract_outputs(&mut runtime) +} + +fn extract_outputs(runtime: &mut deno_core::JsRuntime) -> wfe_core::Result { let outputs = { let state = runtime.op_state(); let mut state = state.borrow_mut(); diff --git a/wfe-yaml/tests/deno.rs b/wfe-yaml/tests/deno.rs index 454ab17..69e5973 100644 --- a/wfe-yaml/tests/deno.rs +++ b/wfe-yaml/tests/deno.rs @@ -1,10 +1,12 @@ #![cfg(feature = "deno")] use std::collections::HashMap; +use std::io::Write; use wfe_yaml::executors::deno::config::{DenoConfig, DenoPermissions}; +use wfe_yaml::executors::deno::module_loader::WfeModuleLoader; use wfe_yaml::executors::deno::permissions::PermissionChecker; -use wfe_yaml::executors::deno::runtime::create_runtime; +use wfe_yaml::executors::deno::runtime::{create_runtime, would_auto_add_esm_sh}; use wfe_yaml::executors::deno::step::DenoStep; use wfe_core::models::execution_pointer::ExecutionPointer; @@ -267,3 +269,329 @@ workflow: let (key, _factory) = &compiled.step_factories[0]; assert!(key.contains("deno"), "factory key should contain 'deno', got: {key}"); } + +// --------------------------------------------------------------------------- +// Phase 4: HTTP op integration tests +// --------------------------------------------------------------------------- + +fn config_with_net(script: &str, net_hosts: &[&str]) -> DenoConfig { + DenoConfig { + script: Some(script.to_string()), + file: None, + permissions: DenoPermissions { + net: net_hosts.iter().map(|s| s.to_string()).collect(), + ..Default::default() + }, + modules: vec![], + env: HashMap::new(), + timeout_ms: Some(10000), + } +} + +#[tokio::test] +async fn deno_fetch_allowed_host() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + let script = format!( + r#" + const resp = await fetch("{}"); + output("status", resp.status); + output("body", await resp.text()); + "#, + server.uri() + ); + let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"])); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["status"], serde_json::json!(200)); + assert_eq!(data["body"], serde_json::json!("ok")); +} + +#[tokio::test] +async fn deno_fetch_denied_host() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + // Do NOT add 127.0.0.1 to net permissions. + let script = format!( + r#" + try {{ + await fetch("{}"); + output("result", "should_not_reach"); + }} catch (e) {{ + output("error", e.message || String(e)); + }} + "#, + server.uri() + ); + let mut step = DenoStep::new(config_with_net(&script, &[])); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert!( + data.get("error").is_some(), + "expected permission error, got: {data:?}" + ); + let err_msg = data["error"].as_str().unwrap(); + assert!( + err_msg.contains("Permission denied"), + "got: {err_msg}" + ); +} + +#[tokio::test] +async fn deno_fetch_returns_json() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"name": "alice", "age": 30})), + ) + .mount(&server) + .await; + + let script = format!( + r#" + const resp = await fetch("{}"); + const data = await resp.json(); + output("name", data.name); + output("age", data.age); + "#, + server.uri() + ); + let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"])); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["name"], serde_json::json!("alice")); + assert_eq!(data["age"], serde_json::json!(30)); +} + +#[tokio::test] +async fn deno_fetch_post_with_body() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::header("content-type", "application/json")) + .and(wiremock::matchers::body_json(serde_json::json!({"key": "val"}))) + .respond_with(wiremock::ResponseTemplate::new(201).set_body_string("created")) + .mount(&server) + .await; + + let script = format!( + r#" + const resp = await fetch("{}", {{ + method: "POST", + headers: {{ "content-type": "application/json" }}, + body: JSON.stringify({{ key: "val" }}) + }}); + output("status", resp.status); + output("body", await resp.text()); + "#, + server.uri() + ); + let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"])); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["status"], serde_json::json!(201)); + assert_eq!(data["body"], serde_json::json!("created")); +} + +#[tokio::test] +async fn deno_fetch_no_permissions_denies_all() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200)) + .mount(&server) + .await; + + // Empty net allowlist should deny everything. + let script = format!( + r#" + try {{ + await fetch("{}"); + output("result", "should_not_reach"); + }} catch (e) {{ + output("denied", true); + }} + "#, + server.uri() + ); + let mut step = DenoStep::new(config_with_net(&script, &[])); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["denied"], serde_json::json!(true)); +} + +// --------------------------------------------------------------------------- +// Phase 5: Module loader integration tests +// --------------------------------------------------------------------------- + +#[test] +fn deno_module_loader_resolves_npm_to_esm_sh() { + let result = WfeModuleLoader::resolve_npm_specifier("npm:lodash@4.17.21"); + assert_eq!(result, Some("https://esm.sh/lodash@4.17.21".to_string())); + + let scoped = WfeModuleLoader::resolve_npm_specifier("npm:@scope/pkg@1.0.0"); + assert_eq!(scoped, Some("https://esm.sh/@scope/pkg@1.0.0".to_string())); + + // Non-npm specifiers return None. + assert!(WfeModuleLoader::resolve_npm_specifier("https://cdn.com/mod.js").is_none()); + assert!(WfeModuleLoader::resolve_npm_specifier("./local.js").is_none()); +} + +#[test] +fn deno_esm_sh_auto_added_to_net_allowlist() { + // When modules are declared, esm.sh should be auto-added. + let config = DenoConfig { + script: Some("1".to_string()), + file: None, + permissions: DenoPermissions::default(), + modules: vec!["npm:lodash@4".to_string()], + env: HashMap::new(), + timeout_ms: None, + }; + assert!(would_auto_add_esm_sh(&config)); + + // When no modules, esm.sh is not added. + let config_no_modules = DenoConfig { + script: Some("1".to_string()), + file: None, + permissions: DenoPermissions::default(), + modules: vec![], + env: HashMap::new(), + timeout_ms: None, + }; + assert!(!would_auto_add_esm_sh(&config_no_modules)); + + // When esm.sh already present, not duplicated. + let config_already = DenoConfig { + script: Some("1".to_string()), + file: None, + permissions: DenoPermissions { + net: vec!["esm.sh".to_string()], + ..Default::default() + }, + modules: vec!["npm:lodash@4".to_string()], + env: HashMap::new(), + timeout_ms: None, + }; + assert!(!would_auto_add_esm_sh(&config_already)); +} + +#[tokio::test] +async fn deno_import_local_file() { + // Create a temp JS file to import. + let dir = tempfile::tempdir().unwrap(); + let helper_path = dir.path().join("helper.js"); + let mut f = std::fs::File::create(&helper_path).unwrap(); + writeln!(f, "export function greet(name) {{ return `hello ${{name}}`; }}").unwrap(); + drop(f); + + let main_path = dir.path().join("main.js"); + let main_code = format!( + r#"import {{ greet }} from "file://{}"; +output("greeting", greet("world"));"#, + helper_path.to_str().unwrap() + ); + std::fs::write(&main_path, &main_code).unwrap(); + + let config = DenoConfig { + script: None, + file: Some(main_path.to_str().unwrap().to_string()), + permissions: DenoPermissions { + read: vec![dir.path().to_str().unwrap().to_string()], + ..Default::default() + }, + modules: vec![], + env: HashMap::new(), + timeout_ms: Some(5000), + }; + + let mut step = DenoStep::new(config); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["greeting"], serde_json::json!("hello world")); +} + +#[tokio::test] +async fn deno_dynamic_import_denied() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_string("export const x = 1;"), + ) + .mount(&server) + .await; + + // dynamic_import defaults to false. + let script = format!( + r#" + try {{ + await import("{}/mod.js"); + output("result", "should_not_reach"); + }} catch (e) {{ + output("error", e.message || String(e)); + }} + "#, + server.uri() + ); + let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"])); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert!( + data.get("error").is_some(), + "expected dynamic import to be denied, got: {data:?}" + ); + let err_msg = data["error"].as_str().unwrap(); + assert!( + err_msg.contains("Dynamic import") || err_msg.contains("not allowed"), + "expected dynamic import denied error, got: {err_msg}" + ); +} + +#[tokio::test] +async fn deno_import_npm_module() { + // Use a tiny npm package via esm.sh. + let script = r#" + import isNumber from "npm:is-number@7.0.0"; + output("result", isNumber(42)); + "#; + let config = DenoConfig { + script: Some(script.to_string()), + file: None, + permissions: DenoPermissions { + net: vec!["esm.sh".to_string()], + ..Default::default() + }, + modules: vec!["npm:is-number@7.0.0".to_string()], + env: HashMap::new(), + timeout_ms: Some(30000), + }; + + let mut step = DenoStep::new(config); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["result"], serde_json::json!(true)); +}