feat(wfe-yaml): add deno_core JS/TS executor with sandboxed permissions
Secure JavaScript/TypeScript execution in workflow steps via deno_core, behind the `deno` feature flag. Security features: - Per-step permission system: net host allowlist, filesystem read/write path restrictions, env var allowlist, subprocess spawn control - V8 heap limits (64MB default) prevent memory exhaustion - Execution timeout with V8 isolate termination for sync infinite loops - Path traversal detection blocks ../ escape attempts - Dynamic import rejection unless explicitly enabled Workflow I/O ops: - inputs() — read workflow data as JSON - output(key, value) — set step outputs - log(message) — structured tracing Architecture: - JsRuntime runs on dedicated thread (V8 is !Send) - PermissionChecker enforced on every I/O op via OpState - DenoStep implements StepBody, integrates with existing compiler - Step type dispatch: "shell" or "deno" in YAML 34 new tests (12 permission unit, 3 config, 2 runtime, 18 integration).
This commit is contained in:
@@ -4,6 +4,10 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "YAML workflow definitions for WFE"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
deno = ["deno_core", "url"]
|
||||
|
||||
[dependencies]
|
||||
wfe-core = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -14,9 +18,12 @@ tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
deno_core = { workspace = true, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util", "process"] }
|
||||
tokio-util = "0.7"
|
||||
wfe-core = { workspace = true, features = ["test-support"] }
|
||||
wfe = { path = "../wfe" }
|
||||
|
||||
@@ -6,6 +6,8 @@ use wfe_core::traits::StepBody;
|
||||
|
||||
use crate::error::YamlWorkflowError;
|
||||
use crate::executors::shell::{ShellConfig, ShellStep};
|
||||
#[cfg(feature = "deno")]
|
||||
use crate::executors::deno::{DenoConfig, DenoPermissions, DenoStep};
|
||||
use crate::schema::{WorkflowSpec, YamlErrorBehavior, YamlStep};
|
||||
|
||||
/// Factory type alias for step creation closures.
|
||||
@@ -69,20 +71,24 @@ fn compile_steps(
|
||||
definition.steps.push(container);
|
||||
main_step_ids.push(container_id);
|
||||
} else {
|
||||
// Regular step (shell).
|
||||
// Regular step.
|
||||
let step_id = *next_id;
|
||||
*next_id += 1;
|
||||
|
||||
let step_type_key = format!("wfe_yaml::shell::{}", yaml_step.name);
|
||||
let config = build_shell_config(yaml_step)?;
|
||||
let step_type = yaml_step
|
||||
.step_type
|
||||
.as_deref()
|
||||
.unwrap_or("shell");
|
||||
|
||||
let (step_type_key, step_config_value, factory): (
|
||||
String,
|
||||
serde_json::Value,
|
||||
StepFactory,
|
||||
) = build_step_config_and_factory(yaml_step, step_type)?;
|
||||
|
||||
let mut wf_step = WorkflowStep::new(step_id, &step_type_key);
|
||||
wf_step.name = Some(yaml_step.name.clone());
|
||||
wf_step.step_config = Some(serde_json::to_value(&config).map_err(|e| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
"Failed to serialize shell config: {e}"
|
||||
))
|
||||
})?);
|
||||
wf_step.step_config = Some(step_config_value);
|
||||
|
||||
if let Some(ref eb) = yaml_step.error_behavior {
|
||||
wf_step.error_behavior = Some(map_error_behavior(eb)?);
|
||||
@@ -93,31 +99,22 @@ fn compile_steps(
|
||||
let comp_id = *next_id;
|
||||
*next_id += 1;
|
||||
|
||||
let comp_key = format!("wfe_yaml::shell::{}", on_failure.name);
|
||||
let comp_config = build_shell_config(on_failure)?;
|
||||
let on_failure_type = on_failure
|
||||
.step_type
|
||||
.as_deref()
|
||||
.unwrap_or("shell");
|
||||
let (comp_key, comp_config_value, comp_factory) =
|
||||
build_step_config_and_factory(on_failure, on_failure_type)?;
|
||||
|
||||
let mut comp_step = WorkflowStep::new(comp_id, &comp_key);
|
||||
comp_step.name = Some(on_failure.name.clone());
|
||||
comp_step.step_config =
|
||||
Some(serde_json::to_value(&comp_config).map_err(|e| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
"Failed to serialize shell config: {e}"
|
||||
))
|
||||
})?);
|
||||
comp_step.step_config = Some(comp_config_value);
|
||||
|
||||
wf_step.compensation_step_id = Some(comp_id);
|
||||
wf_step.error_behavior = Some(ErrorBehavior::Compensate);
|
||||
|
||||
definition.steps.push(comp_step);
|
||||
|
||||
let comp_config_clone = comp_config.clone();
|
||||
factories.push((
|
||||
comp_key,
|
||||
Box::new(move || {
|
||||
Box::new(ShellStep::new(comp_config_clone.clone()))
|
||||
as Box<dyn StepBody>
|
||||
}),
|
||||
));
|
||||
factories.push((comp_key, comp_factory));
|
||||
}
|
||||
|
||||
// Handle on_success: insert between this step and the next.
|
||||
@@ -125,17 +122,16 @@ fn compile_steps(
|
||||
let success_id = *next_id;
|
||||
*next_id += 1;
|
||||
|
||||
let success_key = format!("wfe_yaml::shell::{}", on_success.name);
|
||||
let success_config = build_shell_config(on_success)?;
|
||||
let on_success_type = on_success
|
||||
.step_type
|
||||
.as_deref()
|
||||
.unwrap_or("shell");
|
||||
let (success_key, success_config_value, success_factory) =
|
||||
build_step_config_and_factory(on_success, on_success_type)?;
|
||||
|
||||
let mut success_step = WorkflowStep::new(success_id, &success_key);
|
||||
success_step.name = Some(on_success.name.clone());
|
||||
success_step.step_config =
|
||||
Some(serde_json::to_value(&success_config).map_err(|e| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
"Failed to serialize shell config: {e}"
|
||||
))
|
||||
})?);
|
||||
success_step.step_config = Some(success_config_value);
|
||||
|
||||
// Wire main step -> on_success step.
|
||||
wf_step.outcomes.push(StepOutcome {
|
||||
@@ -145,15 +141,7 @@ fn compile_steps(
|
||||
});
|
||||
|
||||
definition.steps.push(success_step);
|
||||
|
||||
let success_config_clone = success_config.clone();
|
||||
factories.push((
|
||||
success_key,
|
||||
Box::new(move || {
|
||||
Box::new(ShellStep::new(success_config_clone.clone()))
|
||||
as Box<dyn StepBody>
|
||||
}),
|
||||
));
|
||||
factories.push((success_key, success_factory));
|
||||
}
|
||||
|
||||
// Handle ensure: create an ensure step wired after both paths.
|
||||
@@ -161,17 +149,16 @@ fn compile_steps(
|
||||
let ensure_id = *next_id;
|
||||
*next_id += 1;
|
||||
|
||||
let ensure_key = format!("wfe_yaml::shell::{}", ensure.name);
|
||||
let ensure_config = build_shell_config(ensure)?;
|
||||
let ensure_type = ensure
|
||||
.step_type
|
||||
.as_deref()
|
||||
.unwrap_or("shell");
|
||||
let (ensure_key, ensure_config_value, ensure_factory) =
|
||||
build_step_config_and_factory(ensure, ensure_type)?;
|
||||
|
||||
let mut ensure_step = WorkflowStep::new(ensure_id, &ensure_key);
|
||||
ensure_step.name = Some(ensure.name.clone());
|
||||
ensure_step.step_config =
|
||||
Some(serde_json::to_value(&ensure_config).map_err(|e| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
"Failed to serialize shell config: {e}"
|
||||
))
|
||||
})?);
|
||||
ensure_step.step_config = Some(ensure_config_value);
|
||||
|
||||
// Wire main step -> ensure (if no on_success already).
|
||||
if yaml_step.on_success.is_none() {
|
||||
@@ -183,27 +170,13 @@ fn compile_steps(
|
||||
}
|
||||
|
||||
definition.steps.push(ensure_step);
|
||||
|
||||
let ensure_config_clone = ensure_config.clone();
|
||||
factories.push((
|
||||
ensure_key,
|
||||
Box::new(move || {
|
||||
Box::new(ShellStep::new(ensure_config_clone.clone()))
|
||||
as Box<dyn StepBody>
|
||||
}),
|
||||
));
|
||||
factories.push((ensure_key, ensure_factory));
|
||||
}
|
||||
|
||||
definition.steps.push(wf_step);
|
||||
|
||||
// Register factory for main step.
|
||||
let config_clone = config.clone();
|
||||
factories.push((
|
||||
step_type_key,
|
||||
Box::new(move || {
|
||||
Box::new(ShellStep::new(config_clone.clone())) as Box<dyn StepBody>
|
||||
}),
|
||||
));
|
||||
factories.push((step_type_key, factory));
|
||||
|
||||
main_step_ids.push(step_id);
|
||||
}
|
||||
@@ -243,6 +216,90 @@ fn compile_steps(
|
||||
Ok(main_step_ids)
|
||||
}
|
||||
|
||||
fn build_step_config_and_factory(
|
||||
step: &YamlStep,
|
||||
step_type: &str,
|
||||
) -> Result<(String, serde_json::Value, StepFactory), YamlWorkflowError> {
|
||||
match step_type {
|
||||
"shell" => {
|
||||
let config = build_shell_config(step)?;
|
||||
let key = format!("wfe_yaml::shell::{}", step.name);
|
||||
let value = serde_json::to_value(&config).map_err(|e| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
"Failed to serialize shell config: {e}"
|
||||
))
|
||||
})?;
|
||||
let config_clone = config.clone();
|
||||
let factory: StepFactory = Box::new(move || {
|
||||
Box::new(ShellStep::new(config_clone.clone())) as Box<dyn StepBody>
|
||||
});
|
||||
Ok((key, value, factory))
|
||||
}
|
||||
#[cfg(feature = "deno")]
|
||||
"deno" => {
|
||||
let config = build_deno_config(step)?;
|
||||
let key = format!("wfe_yaml::deno::{}", step.name);
|
||||
let value = serde_json::to_value(&config).map_err(|e| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
"Failed to serialize deno config: {e}"
|
||||
))
|
||||
})?;
|
||||
let config_clone = config.clone();
|
||||
let factory: StepFactory = Box::new(move || {
|
||||
Box::new(DenoStep::new(config_clone.clone())) as Box<dyn StepBody>
|
||||
});
|
||||
Ok((key, value, factory))
|
||||
}
|
||||
other => Err(YamlWorkflowError::Compilation(format!(
|
||||
"Unknown step type: '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "deno")]
|
||||
fn build_deno_config(step: &YamlStep) -> Result<DenoConfig, YamlWorkflowError> {
|
||||
let config = step.config.as_ref().ok_or_else(|| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
"Deno step '{}' is missing 'config' section",
|
||||
step.name
|
||||
))
|
||||
})?;
|
||||
|
||||
let script = config.script.clone();
|
||||
let file = config.file.clone();
|
||||
|
||||
if script.is_none() && file.is_none() {
|
||||
return Err(YamlWorkflowError::Compilation(format!(
|
||||
"Deno step '{}' must have 'script' or 'file' in config",
|
||||
step.name
|
||||
)));
|
||||
}
|
||||
|
||||
let timeout_ms = config.timeout.as_ref().and_then(|t| parse_duration_ms(t));
|
||||
|
||||
let permissions = config
|
||||
.permissions
|
||||
.as_ref()
|
||||
.map(|p| DenoPermissions {
|
||||
net: p.net.clone(),
|
||||
read: p.read.clone(),
|
||||
write: p.write.clone(),
|
||||
env: p.env.clone(),
|
||||
run: p.run,
|
||||
dynamic_import: p.dynamic_import,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(DenoConfig {
|
||||
script,
|
||||
file,
|
||||
permissions,
|
||||
modules: config.modules.clone(),
|
||||
env: config.env.clone(),
|
||||
timeout_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_shell_config(step: &YamlStep) -> Result<ShellConfig, YamlWorkflowError> {
|
||||
let config = step.config.as_ref().ok_or_else(|| {
|
||||
YamlWorkflowError::Compilation(format!(
|
||||
|
||||
121
wfe-yaml/src/executors/deno/config.rs
Normal file
121
wfe-yaml/src/executors/deno/config.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for a Deno JavaScript/TypeScript step.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DenoConfig {
|
||||
/// Inline script source code.
|
||||
pub script: Option<String>,
|
||||
/// Path to a script file.
|
||||
pub file: Option<String>,
|
||||
/// Permission allowlists.
|
||||
#[serde(default)]
|
||||
pub permissions: DenoPermissions,
|
||||
/// ES modules to pre-load.
|
||||
#[serde(default)]
|
||||
pub modules: Vec<String>,
|
||||
/// Extra environment variables.
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
/// Execution timeout in milliseconds.
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Fine-grained permission allowlists for the Deno sandbox.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DenoPermissions {
|
||||
/// Allowed network hosts (e.g. `["api.example.com"]`).
|
||||
#[serde(default)]
|
||||
pub net: Vec<String>,
|
||||
/// Allowed file-system read paths.
|
||||
#[serde(default)]
|
||||
pub read: Vec<String>,
|
||||
/// Allowed file-system write paths.
|
||||
#[serde(default)]
|
||||
pub write: Vec<String>,
|
||||
/// Allowed environment variable names.
|
||||
#[serde(default)]
|
||||
pub env: Vec<String>,
|
||||
/// Whether sub-process spawning is allowed.
|
||||
#[serde(default)]
|
||||
pub run: bool,
|
||||
/// Whether dynamic `import()` is allowed.
|
||||
#[serde(default)]
|
||||
pub dynamic_import: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_full_config() {
|
||||
let config = DenoConfig {
|
||||
script: Some("console.log('hi')".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions {
|
||||
net: vec!["example.com".to_string()],
|
||||
read: vec!["/tmp".to_string()],
|
||||
write: vec!["/tmp/out".to_string()],
|
||||
env: vec!["HOME".to_string()],
|
||||
run: false,
|
||||
dynamic_import: true,
|
||||
},
|
||||
modules: vec!["https://deno.land/std/mod.ts".to_string()],
|
||||
env: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("FOO".to_string(), "bar".to_string());
|
||||
m
|
||||
},
|
||||
timeout_ms: Some(5000),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: DenoConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.script.as_deref(), Some("console.log('hi')"));
|
||||
assert!(deserialized.file.is_none());
|
||||
assert_eq!(deserialized.permissions.net, vec!["example.com"]);
|
||||
assert_eq!(deserialized.permissions.read, vec!["/tmp"]);
|
||||
assert_eq!(deserialized.permissions.write, vec!["/tmp/out"]);
|
||||
assert_eq!(deserialized.permissions.env, vec!["HOME"]);
|
||||
assert!(!deserialized.permissions.run);
|
||||
assert!(deserialized.permissions.dynamic_import);
|
||||
assert_eq!(deserialized.modules.len(), 1);
|
||||
assert_eq!(deserialized.env.get("FOO").unwrap(), "bar");
|
||||
assert_eq!(deserialized.timeout_ms, Some(5000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_defaults() {
|
||||
let json = r#"{"script": "1+1"}"#;
|
||||
let config: DenoConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.script.as_deref(), Some("1+1"));
|
||||
assert!(config.file.is_none());
|
||||
assert!(config.permissions.net.is_empty());
|
||||
assert!(config.permissions.read.is_empty());
|
||||
assert!(config.permissions.write.is_empty());
|
||||
assert!(config.permissions.env.is_empty());
|
||||
assert!(!config.permissions.run);
|
||||
assert!(!config.permissions.dynamic_import);
|
||||
assert!(config.modules.is_empty());
|
||||
assert!(config.env.is_empty());
|
||||
assert!(config.timeout_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_file_config() {
|
||||
let config = DenoConfig {
|
||||
script: None,
|
||||
file: Some("./scripts/run.ts".to_string()),
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: Some(10000),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: DenoConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(deserialized.script.is_none());
|
||||
assert_eq!(deserialized.file.as_deref(), Some("./scripts/run.ts"));
|
||||
assert_eq!(deserialized.timeout_ms, Some(10000));
|
||||
}
|
||||
}
|
||||
3
wfe-yaml/src/executors/deno/js/bootstrap.js
vendored
Normal file
3
wfe-yaml/src/executors/deno/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
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);
|
||||
9
wfe-yaml/src/executors/deno/mod.rs
Normal file
9
wfe-yaml/src/executors/deno/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod config;
|
||||
pub mod ops;
|
||||
pub mod permissions;
|
||||
pub mod runtime;
|
||||
pub mod step;
|
||||
|
||||
pub use config::{DenoConfig, DenoPermissions};
|
||||
pub use permissions::PermissionChecker;
|
||||
pub use step::DenoStep;
|
||||
3
wfe-yaml/src/executors/deno/ops/mod.rs
Normal file
3
wfe-yaml/src/executors/deno/ops/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod workflow;
|
||||
|
||||
pub use workflow::{StepMeta, StepOutputs, WorkflowInputs};
|
||||
52
wfe-yaml/src/executors/deno/ops/workflow.rs
Normal file
52
wfe-yaml/src/executors/deno/ops/workflow.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deno_core::op2;
|
||||
use deno_core::OpState;
|
||||
|
||||
/// Workflow data available to the script via `inputs()`.
|
||||
pub struct WorkflowInputs {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Accumulates key/value outputs set by the script via `output(key, value)`.
|
||||
pub struct StepOutputs {
|
||||
pub map: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Metadata about the currently executing step.
|
||||
pub struct StepMeta {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Returns the workflow input data to JavaScript.
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub fn op_inputs(state: &mut OpState) -> serde_json::Value {
|
||||
let inputs = state.borrow::<WorkflowInputs>();
|
||||
inputs.data.clone()
|
||||
}
|
||||
|
||||
/// Stores a key/value pair in the step outputs.
|
||||
#[op2]
|
||||
pub fn op_output(
|
||||
state: &mut OpState,
|
||||
#[string] key: String,
|
||||
#[serde] value: serde_json::Value,
|
||||
) {
|
||||
let outputs = state.borrow_mut::<StepOutputs>();
|
||||
outputs.map.insert(key, value);
|
||||
}
|
||||
|
||||
/// Logs a message via the tracing crate.
|
||||
#[op2(fast)]
|
||||
pub fn op_log(state: &mut OpState, #[string] msg: String) {
|
||||
let name = state.borrow::<StepMeta>().name.clone();
|
||||
tracing::info!(step = %name, "{}", msg);
|
||||
}
|
||||
|
||||
deno_core::extension!(
|
||||
wfe_ops,
|
||||
ops = [op_inputs, op_output, op_log],
|
||||
esm_entry_point = "ext:wfe/bootstrap.js",
|
||||
esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"],
|
||||
);
|
||||
257
wfe-yaml/src/executors/deno/permissions.rs
Normal file
257
wfe-yaml/src/executors/deno/permissions.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use super::config::DenoPermissions;
|
||||
|
||||
/// Error returned when a permission check fails.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionError {
|
||||
pub kind: &'static str,
|
||||
pub resource: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for PermissionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Permission denied: {} access to '{}'",
|
||||
self.kind, self.resource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PermissionError {}
|
||||
|
||||
/// Checks whether operations are allowed based on the configured permissions.
|
||||
pub struct PermissionChecker {
|
||||
net_hosts: Vec<String>,
|
||||
read_paths: Vec<String>,
|
||||
write_paths: Vec<String>,
|
||||
env_vars: Vec<String>,
|
||||
pub allow_run: bool,
|
||||
pub allow_dynamic_import: bool,
|
||||
}
|
||||
|
||||
impl PermissionChecker {
|
||||
/// Build a checker from the YAML permission config.
|
||||
pub fn from_config(perms: &DenoPermissions) -> Self {
|
||||
Self {
|
||||
net_hosts: perms.net.clone(),
|
||||
read_paths: perms.read.clone(),
|
||||
write_paths: perms.write.clone(),
|
||||
env_vars: perms.env.clone(),
|
||||
allow_run: perms.run,
|
||||
allow_dynamic_import: perms.dynamic_import,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether network access to `host` is allowed.
|
||||
pub fn check_net(&self, host: &str) -> Result<(), PermissionError> {
|
||||
if self.net_hosts.iter().any(|h| h == host) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PermissionError {
|
||||
kind: "net",
|
||||
resource: host.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether file-system read access to `path` is allowed.
|
||||
pub fn check_read(&self, path: &str) -> Result<(), PermissionError> {
|
||||
// Block path traversal attempts.
|
||||
if Self::has_traversal(path) {
|
||||
return Err(PermissionError {
|
||||
kind: "read",
|
||||
resource: path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let target = Path::new(path);
|
||||
if self
|
||||
.read_paths
|
||||
.iter()
|
||||
.any(|allowed| target.starts_with(allowed))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PermissionError {
|
||||
kind: "read",
|
||||
resource: path.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether file-system write access to `path` is allowed.
|
||||
pub fn check_write(&self, path: &str) -> Result<(), PermissionError> {
|
||||
if Self::has_traversal(path) {
|
||||
return Err(PermissionError {
|
||||
kind: "write",
|
||||
resource: path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let target = Path::new(path);
|
||||
if self
|
||||
.write_paths
|
||||
.iter()
|
||||
.any(|allowed| target.starts_with(allowed))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PermissionError {
|
||||
kind: "write",
|
||||
resource: path.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether reading environment variable `var` is allowed.
|
||||
pub fn check_env(&self, var: &str) -> Result<(), PermissionError> {
|
||||
if self.env_vars.iter().any(|v| v == var) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PermissionError {
|
||||
kind: "env",
|
||||
resource: var.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect `..` path traversal components.
|
||||
fn has_traversal(path: &str) -> bool {
|
||||
Path::new(path).components().any(|c| {
|
||||
matches!(c, std::path::Component::ParentDir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn perms(
|
||||
net: &[&str],
|
||||
read: &[&str],
|
||||
write: &[&str],
|
||||
env: &[&str],
|
||||
) -> PermissionChecker {
|
||||
PermissionChecker::from_config(&DenoPermissions {
|
||||
net: net.iter().map(|s| s.to_string()).collect(),
|
||||
read: read.iter().map(|s| s.to_string()).collect(),
|
||||
write: write.iter().map(|s| s.to_string()).collect(),
|
||||
env: env.iter().map(|s| s.to_string()).collect(),
|
||||
run: false,
|
||||
dynamic_import: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_allowed_host_passes() {
|
||||
let checker = perms(&["api.example.com"], &[], &[], &[]);
|
||||
assert!(checker.check_net("api.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_denied_host_fails() {
|
||||
let checker = perms(&["api.example.com"], &[], &[], &[]);
|
||||
let err = checker.check_net("evil.com").unwrap_err();
|
||||
assert_eq!(err.kind, "net");
|
||||
assert_eq!(err.resource, "evil.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_empty_allowlist_denies_all() {
|
||||
let checker = perms(&[], &[], &[], &[]);
|
||||
assert!(checker.check_net("anything.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_allowed_path_passes() {
|
||||
let checker = perms(&[], &["/tmp"], &[], &[]);
|
||||
assert!(checker.check_read("/tmp/data.json").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_denied_path_fails() {
|
||||
let checker = perms(&[], &["/tmp"], &[], &[]);
|
||||
let err = checker.check_read("/etc/passwd").unwrap_err();
|
||||
assert_eq!(err.kind, "read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_path_traversal_blocked() {
|
||||
let checker = perms(&[], &["/tmp"], &[], &[]);
|
||||
let err = checker
|
||||
.check_read("/tmp/../../../etc/passwd")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, "read");
|
||||
assert!(err.resource.contains(".."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_allowed_path_passes() {
|
||||
let checker = perms(&[], &[], &["/tmp/out"], &[]);
|
||||
assert!(checker.check_write("/tmp/out/result.json").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_denied_path_fails() {
|
||||
let checker = perms(&[], &[], &["/tmp/out"], &[]);
|
||||
let err = checker.check_write("/var/data").unwrap_err();
|
||||
assert_eq!(err.kind, "write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_path_traversal_blocked() {
|
||||
let checker = perms(&[], &[], &["/tmp/out"], &[]);
|
||||
assert!(checker
|
||||
.check_write("/tmp/out/../../etc/shadow")
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_allowed_var_passes() {
|
||||
let checker = perms(&[], &[], &[], &["HOME", "PATH"]);
|
||||
assert!(checker.check_env("HOME").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_denied_var_fails() {
|
||||
let checker = perms(&[], &[], &[], &["HOME"]);
|
||||
let err = checker.check_env("SECRET_KEY").unwrap_err();
|
||||
assert_eq!(err.kind, "env");
|
||||
assert_eq!(err.resource, "SECRET_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_error_display() {
|
||||
let err = PermissionError {
|
||||
kind: "net",
|
||||
resource: "evil.com".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Permission denied: net access to 'evil.com'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_copies_all_fields() {
|
||||
let config = DenoPermissions {
|
||||
net: vec!["a.com".to_string()],
|
||||
read: vec!["/r".to_string()],
|
||||
write: vec!["/w".to_string()],
|
||||
env: vec!["E".to_string()],
|
||||
run: true,
|
||||
dynamic_import: true,
|
||||
};
|
||||
let checker = PermissionChecker::from_config(&config);
|
||||
assert!(checker.allow_run);
|
||||
assert!(checker.allow_dynamic_import);
|
||||
assert!(checker.check_net("a.com").is_ok());
|
||||
assert!(checker.check_read("/r/file").is_ok());
|
||||
assert!(checker.check_write("/w/file").is_ok());
|
||||
assert!(checker.check_env("E").is_ok());
|
||||
}
|
||||
}
|
||||
81
wfe-yaml/src/executors/deno/runtime.rs
Normal file
81
wfe-yaml/src/executors/deno/runtime.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deno_core::JsRuntime;
|
||||
use deno_core::RuntimeOptions;
|
||||
use wfe_core::WfeError;
|
||||
|
||||
use super::config::DenoConfig;
|
||||
use super::ops::workflow::{wfe_ops, StepMeta, StepOutputs, WorkflowInputs};
|
||||
use super::permissions::PermissionChecker;
|
||||
|
||||
/// Create a configured `JsRuntime` for executing a workflow step script.
|
||||
pub fn create_runtime(
|
||||
config: &DenoConfig,
|
||||
workflow_data: serde_json::Value,
|
||||
step_name: &str,
|
||||
) -> Result<JsRuntime, WfeError> {
|
||||
let ext = wfe_ops::init();
|
||||
|
||||
let runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![ext],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Populate OpState with our workflow types.
|
||||
{
|
||||
let state = runtime.op_state();
|
||||
let mut state = state.borrow_mut();
|
||||
state.put(WorkflowInputs {
|
||||
data: workflow_data,
|
||||
});
|
||||
state.put(StepOutputs {
|
||||
map: HashMap::new(),
|
||||
});
|
||||
state.put(StepMeta {
|
||||
name: step_name.to_string(),
|
||||
});
|
||||
state.put(PermissionChecker::from_config(&config.permissions));
|
||||
}
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::config::DenoPermissions;
|
||||
|
||||
#[test]
|
||||
fn create_runtime_succeeds() {
|
||||
let config = DenoConfig {
|
||||
script: Some("1+1".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
let runtime = create_runtime(&config, serde_json::json!({}), "test-step");
|
||||
assert!(runtime.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_runtime_has_op_state() {
|
||||
let config = DenoConfig {
|
||||
script: None,
|
||||
file: None,
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
let runtime =
|
||||
create_runtime(&config, serde_json::json!({"key": "val"}), "my-step").unwrap();
|
||||
let state = runtime.op_state();
|
||||
let state = state.borrow();
|
||||
let inputs = state.borrow::<WorkflowInputs>();
|
||||
assert_eq!(inputs.data, serde_json::json!({"key": "val"}));
|
||||
let meta = state.borrow::<StepMeta>();
|
||||
assert_eq!(meta.name, "my-step");
|
||||
}
|
||||
}
|
||||
156
wfe-yaml/src/executors/deno/step.rs
Normal file
156
wfe-yaml/src/executors/deno/step.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use async_trait::async_trait;
|
||||
use wfe_core::models::ExecutionResult;
|
||||
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||
use wfe_core::WfeError;
|
||||
|
||||
use super::config::DenoConfig;
|
||||
use super::ops::workflow::StepOutputs;
|
||||
use super::runtime::create_runtime;
|
||||
|
||||
/// A workflow step that executes JavaScript inside a Deno runtime.
|
||||
pub struct DenoStep {
|
||||
config: DenoConfig,
|
||||
}
|
||||
|
||||
impl DenoStep {
|
||||
pub fn new(config: DenoConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StepBody for DenoStep {
|
||||
async fn run(
|
||||
&mut self,
|
||||
context: &StepExecutionContext<'_>,
|
||||
) -> wfe_core::Result<ExecutionResult> {
|
||||
let step_name = context
|
||||
.step
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let workflow_data = context.workflow.data.clone();
|
||||
|
||||
// Resolve script source.
|
||||
let source = if let Some(ref script) = self.config.script {
|
||||
script.clone()
|
||||
} else if let Some(ref file_path) = self.config.file {
|
||||
std::fs::read_to_string(file_path).map_err(|e| {
|
||||
WfeError::StepExecution(format!(
|
||||
"Failed to read deno script file '{}': {}",
|
||||
file_path, e
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
return Err(WfeError::StepExecution(
|
||||
"Deno step must have either 'script' or 'file' configured".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let config = self.config.clone();
|
||||
let timeout_ms = self.config.timeout_ms;
|
||||
|
||||
// JsRuntime is !Send, so we run it on a dedicated thread with its own
|
||||
// single-threaded tokio runtime.
|
||||
let handle = std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
WfeError::StepExecution(format!("Failed to build tokio runtime: {e}"))
|
||||
})?;
|
||||
|
||||
rt.block_on(async move {
|
||||
run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms)
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
// Wait for the thread.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
handle
|
||||
.join()
|
||||
.map_err(|_| WfeError::StepExecution("Deno thread panicked".to_string()))?
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WfeError::StepExecution(format!("Join error: {e}")))?
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_script_inner(
|
||||
config: &DenoConfig,
|
||||
workflow_data: serde_json::Value,
|
||||
step_name: &str,
|
||||
source: &str,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> wfe_core::Result<ExecutionResult> {
|
||||
let mut runtime = create_runtime(config, workflow_data, step_name)?;
|
||||
|
||||
// If a timeout is configured, set up a V8 termination timer.
|
||||
// This handles synchronous infinite loops that never yield to the event loop.
|
||||
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();
|
||||
})
|
||||
});
|
||||
|
||||
// Execute the script.
|
||||
runtime
|
||||
.execute_script("<wfe>", source.to_string())
|
||||
.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 script error: {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
// Run the event loop to completion.
|
||||
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}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
// Extract outputs from OpState.
|
||||
let outputs = {
|
||||
let state = runtime.op_state();
|
||||
let mut state = state.borrow_mut();
|
||||
let step_outputs = state.borrow_mut::<StepOutputs>();
|
||||
std::mem::take(&mut step_outputs.map)
|
||||
};
|
||||
|
||||
let output_data = if outputs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::Value::Object(
|
||||
outputs
|
||||
.into_iter()
|
||||
.collect::<serde_json::Map<String, serde_json::Value>>(),
|
||||
))
|
||||
};
|
||||
|
||||
Ok(ExecutionResult {
|
||||
proceed: true,
|
||||
output_data,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
pub mod shell;
|
||||
|
||||
#[cfg(feature = "deno")]
|
||||
pub mod deno;
|
||||
|
||||
@@ -54,6 +54,27 @@ pub struct StepConfig {
|
||||
pub env: HashMap<String, String>,
|
||||
pub timeout: Option<String>,
|
||||
pub working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub permissions: Option<DenoPermissionsYaml>,
|
||||
#[serde(default)]
|
||||
pub modules: Vec<String>,
|
||||
}
|
||||
|
||||
/// YAML-level permission configuration for Deno steps.
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
pub struct DenoPermissionsYaml {
|
||||
#[serde(default)]
|
||||
pub net: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub read: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub write: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub run: bool,
|
||||
#[serde(default)]
|
||||
pub dynamic_import: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -71,6 +71,24 @@ fn validate_steps(
|
||||
}
|
||||
}
|
||||
|
||||
// Deno steps must have config with script or file.
|
||||
if let Some(ref step_type) = step.step_type
|
||||
&& step_type == "deno"
|
||||
{
|
||||
let config = step.config.as_ref().ok_or_else(|| {
|
||||
YamlWorkflowError::Validation(format!(
|
||||
"Deno step '{}' must have a 'config' section",
|
||||
step.name
|
||||
))
|
||||
})?;
|
||||
if config.script.is_none() && config.file.is_none() {
|
||||
return Err(YamlWorkflowError::Validation(format!(
|
||||
"Deno step '{}' must have 'config.script' or 'config.file'",
|
||||
step.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate step-level error behavior.
|
||||
if let Some(ref eb) = step.error_behavior {
|
||||
validate_error_behavior_type(&eb.behavior_type)?;
|
||||
|
||||
269
wfe-yaml/tests/deno.rs
Normal file
269
wfe-yaml/tests/deno.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
#![cfg(feature = "deno")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wfe_yaml::executors::deno::config::{DenoConfig, DenoPermissions};
|
||||
use wfe_yaml::executors::deno::permissions::PermissionChecker;
|
||||
use wfe_yaml::executors::deno::runtime::create_runtime;
|
||||
use wfe_yaml::executors::deno::step::DenoStep;
|
||||
|
||||
use wfe_core::models::execution_pointer::ExecutionPointer;
|
||||
use wfe_core::models::workflow_definition::WorkflowStep;
|
||||
use wfe_core::models::workflow_instance::WorkflowInstance;
|
||||
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn default_config(script: &str) -> DenoConfig {
|
||||
DenoConfig {
|
||||
script: Some(script.to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_context<'a>(
|
||||
step: &'a WorkflowStep,
|
||||
workflow: &'a WorkflowInstance,
|
||||
pointer: &'a ExecutionPointer,
|
||||
) -> StepExecutionContext<'a> {
|
||||
StepExecutionContext {
|
||||
item: None,
|
||||
execution_pointer: pointer,
|
||||
persistence_data: None,
|
||||
step,
|
||||
workflow,
|
||||
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_test_fixtures(
|
||||
data: serde_json::Value,
|
||||
) -> (WorkflowStep, WorkflowInstance, ExecutionPointer) {
|
||||
let mut step = WorkflowStep::new(0, "deno");
|
||||
step.name = Some("test-deno-step".to_string());
|
||||
let workflow = WorkflowInstance::new("test-workflow", 1, data);
|
||||
let pointer = ExecutionPointer::new(0);
|
||||
(step, workflow, pointer)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_inline_script_completes() {
|
||||
let mut step = DenoStep::new(default_config("1 + 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();
|
||||
assert!(result.proceed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_output_captured() {
|
||||
let mut step = DenoStep::new(default_config(r#"output("greeting", "hello");"#));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
assert!(result.proceed);
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["greeting"], serde_json::json!("hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_inputs_available() {
|
||||
let script = r#"
|
||||
const data = inputs();
|
||||
output("got_name", data.name);
|
||||
"#;
|
||||
let mut step = DenoStep::new(default_config(script));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({"name": "alice"}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["got_name"], serde_json::json!("alice"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_script_error_fails_step() {
|
||||
let mut step = DenoStep::new(default_config("throw new Error('boom');"));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let err = step.run(&ctx).await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("boom") || msg.contains("Error"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_timeout_kills_execution() {
|
||||
let mut config = default_config("while (true) {}");
|
||||
config.timeout_ms = Some(100);
|
||||
let mut step = DenoStep::new(config);
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let err = step.run(&ctx).await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("timed out"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_multiple_outputs() {
|
||||
let script = r#"
|
||||
output("a", 1);
|
||||
output("b", "two");
|
||||
output("c", true);
|
||||
"#;
|
||||
let mut step = DenoStep::new(default_config(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["a"], serde_json::json!(1));
|
||||
assert_eq!(data["b"], serde_json::json!("two"));
|
||||
assert_eq!(data["c"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_log_works() {
|
||||
let mut step = DenoStep::new(default_config(r#"log("hello from deno");"#));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
// Should not crash.
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
assert!(result.proceed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_empty_script_completes() {
|
||||
let mut step = DenoStep::new(default_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();
|
||||
assert!(result.proceed);
|
||||
assert!(result.output_data.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn permission_net_allowed() {
|
||||
let perms = DenoPermissions {
|
||||
net: vec!["api.example.com".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let checker = PermissionChecker::from_config(&perms);
|
||||
assert!(checker.check_net("api.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_net_denied() {
|
||||
let perms = DenoPermissions::default();
|
||||
let checker = PermissionChecker::from_config(&perms);
|
||||
assert!(checker.check_net("evil.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_env_denied() {
|
||||
let perms = DenoPermissions {
|
||||
env: vec!["SAFE_VAR".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let checker = PermissionChecker::from_config(&perms);
|
||||
assert!(checker.check_env("SECRET_KEY").is_err());
|
||||
assert!(checker.check_env("SAFE_VAR").is_ok());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime creation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn runtime_creation_succeeds() {
|
||||
let config = default_config("1");
|
||||
let rt = create_runtime(&config, serde_json::json!({"x": 1}), "test");
|
||||
assert!(rt.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_op_state_populated() {
|
||||
let config = default_config("1");
|
||||
let rt = create_runtime(&config, serde_json::json!({"foo": "bar"}), "my-step").unwrap();
|
||||
let state = rt.op_state();
|
||||
let state = state.borrow();
|
||||
let inputs = state.borrow::<wfe_yaml::executors::deno::ops::WorkflowInputs>();
|
||||
assert_eq!(inputs.data, serde_json::json!({"foo": "bar"}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validation_deno_step_missing_config() {
|
||||
let yaml = r#"
|
||||
workflow:
|
||||
id: test
|
||||
version: 1
|
||||
steps:
|
||||
- name: run_js
|
||||
type: deno
|
||||
"#;
|
||||
let config = HashMap::new();
|
||||
let result = wfe_yaml::load_workflow_from_str(yaml, &config);
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap().to_string();
|
||||
assert!(
|
||||
msg.contains("config") || msg.contains("Deno"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_deno_step_missing_script_and_file() {
|
||||
let yaml = r#"
|
||||
workflow:
|
||||
id: test
|
||||
version: 1
|
||||
steps:
|
||||
- name: run_js
|
||||
type: deno
|
||||
config:
|
||||
env:
|
||||
FOO: bar
|
||||
"#;
|
||||
let config = HashMap::new();
|
||||
let result = wfe_yaml::load_workflow_from_str(yaml, &config);
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap().to_string();
|
||||
assert!(
|
||||
msg.contains("script") || msg.contains("file"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_deno_step_produces_factory() {
|
||||
let yaml = r#"
|
||||
workflow:
|
||||
id: test-deno
|
||||
version: 1
|
||||
steps:
|
||||
- name: js_step
|
||||
type: deno
|
||||
config:
|
||||
script: "output('key', 'val');"
|
||||
"#;
|
||||
let config = HashMap::new();
|
||||
let compiled = wfe_yaml::load_workflow_from_str(yaml, &config).unwrap();
|
||||
assert!(!compiled.step_factories.is_empty());
|
||||
let (key, _factory) = &compiled.step_factories[0];
|
||||
assert!(key.contains("deno"), "factory key should contain 'deno', got: {key}");
|
||||
}
|
||||
Reference in New Issue
Block a user