use oxc_allocator::Allocator;
use oxc_ast::ast::{
DoWhileStatement, ForInStatement, ForOfStatement, ForStatement, Statement, WhileStatement,
};
use oxc_parser::Parser;
use oxc_span::{GetSpan, SourceType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptDiagnostic {
pub code: &'static str,
pub message: String,
pub line: u32,
pub column: u32,
}
impl std::fmt::Display for ScriptDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}:{}: {}", self.line, self.column, self.message)
}
}
const RESERVED: &[&str] = &[
"__tj_tick",
"__fetchConfig",
"__script_input",
"__script_n",
"__script_ctx",
"__zebflow_script_result",
"__zfOps",
"__zfScriptResult",
"__zfReadLocalFile",
"__opsLeft",
"__deadline",
"__r",
"__fn",
];
const WRAPPER_PARAMS: &[&str] = &["input", "n", "ctx"];
const WRAPPER_OPEN: &str = concat!(
"async function __zf_main(input, n, ctx) {",
" var $trigger = (ctx && ctx.trigger) || null;",
" var $nodes = (ctx && ctx.nodes) || {};",
" var $placeholder = (ctx && ctx.placeholder) || {};",
" var $run = { pipeline: (ctx && ctx.pipeline) || '', request_id: (ctx && ctx.request_id) || '' };",
);
#[derive(Debug, Clone, Copy)]
pub struct ScriptPolicy {
pub allow_dynamic_code: bool,
pub allow_import: bool,
pub allow_timers: bool,
pub inject_guards: bool,
}
struct Splice {
at: usize,
text: &'static str,
}
pub fn compile_body(body: &str, policy: ScriptPolicy) -> Result<String, ScriptDiagnostic> {
let wrapped = format!("{WRAPPER_OPEN}\n{body}\n}}");
let allocator = Allocator::default();
let source_type = SourceType::default();
let parsed = Parser::new(&allocator, &wrapped, source_type).parse();
if let Some(err) = parsed.errors.first() {
let offset = err
.labels
.as_ref()
.and_then(|l| l.first().map(|s| s.offset()))
.unwrap_or(0);
let (line, column) = line_col(&wrapped, offset);
return Err(ScriptDiagnostic {
code: "SCRIPT_SYNTAX",
message: format!("script does not parse: {}", err.message),
line,
column,
});
}
let semantic = oxc_semantic::SemanticBuilder::new().build(&parsed.program);
if let Some(err) = semantic.errors.first() {
let offset = err
.labels
.as_ref()
.and_then(|l| l.first().map(|s| s.offset()))
.unwrap_or(0);
let (line, column) = line_col(&wrapped, offset);
let collided = WRAPPER_PARAMS
.iter()
.find(|p| err.message.contains(&format!("`{p}`")));
let message = match collided {
Some(name) => format!(
"`{name}` is already defined: a script body receives `input`, `n` and `ctx` \
as its arguments, so it cannot declare them again. Rename your variable."
),
None => format!("script is not valid: {}", err.message),
};
return Err(ScriptDiagnostic {
code: "SCRIPT_SEMANTIC",
message,
line,
column,
});
}
let mut collector = Collector {
body_starts_at: (WRAPPER_OPEN.len() + 1) as u32,
splices: Vec::new(),
violation: None,
policy,
};
oxc_ast_visit::walk::walk_program(&mut collector, &parsed.program);
if let Some((code, message, offset)) = collector.violation {
let (line, column) = line_col(&wrapped, offset);
return Err(ScriptDiagnostic { code, message, line, column });
}
let mut out = wrapped;
if policy.inject_guards {
collector.splices.sort_by(|a, b| b.at.cmp(&a.at));
for splice in &collector.splices {
out.insert_str(splice.at, splice.text);
}
}
Ok(out)
}
fn line_col(text: &str, offset: usize) -> (u32, u32) {
let upto = &text[..offset.min(text.len())];
let line = upto.matches('\n').count() as u32; let column = upto.rsplit('\n').next().map(str::len).unwrap_or(0) as u32 + 1;
(line.max(1), column)
}
struct Collector {
body_starts_at: u32,
splices: Vec<Splice>,
violation: Option<(&'static str, String, usize)>,
policy: ScriptPolicy,
}
impl Collector {
fn refuse(&mut self, code: &'static str, message: String, offset: u32) {
if self.violation.is_none() {
self.violation = Some((code, message, offset as usize));
}
}
fn note_reserved(&mut self, name: &str, offset: u32) {
if offset < self.body_starts_at {
return; }
if RESERVED.contains(&name) {
self.refuse(
"SCRIPT_RESERVED_BINDING",
format!(
"`{name}` is a runtime-reserved name and cannot be declared or \
assigned; binding it would disable this script's own execution limits"
),
offset,
);
}
}
fn check_callee_name(&mut self, name: &str, offset: u32) {
if !self.policy.allow_dynamic_code && matches!(name, "eval" | "Function") {
self.refuse(
"SCRIPT_DYNAMIC_CODE",
format!("`{name}` is disabled: dynamic code generation is not permitted"),
offset,
);
}
if !self.policy.allow_timers
&& matches!(name, "setTimeout" | "setInterval" | "queueMicrotask")
{
self.refuse(
"SCRIPT_TIMERS",
format!("`{name}` is disabled: timers are not permitted"),
offset,
);
}
}
fn guard_test(&mut self, test_start: u32) {
self.splices.push(Splice {
at: test_start as usize,
text: "__tj_tick(), ",
});
}
fn guard_body(&mut self, body: &Statement<'_>) {
match body {
Statement::BlockStatement(block) => self.splices.push(Splice {
at: block.span.start as usize + 1, text: "__tj_tick();",
}),
other => {
let span = other.span();
self.splices.push(Splice {
at: span.start as usize,
text: "{__tj_tick();",
});
self.splices.push(Splice {
at: span.end as usize,
text: "}",
});
}
}
}
}
impl<'a> oxc_ast_visit::Visit<'a> for Collector {
fn visit_binding_identifier(&mut self, ident: &oxc_ast::ast::BindingIdentifier<'a>) {
self.note_reserved(ident.name.as_str(), ident.span.start);
}
fn visit_assignment_target(&mut self, target: &oxc_ast::ast::AssignmentTarget<'a>) {
use oxc_ast::ast::AssignmentTarget as AT;
match target {
AT::AssignmentTargetIdentifier(ident) => {
self.note_reserved(ident.name.as_str(), ident.span.start);
}
AT::StaticMemberExpression(member) => {
self.note_reserved(member.property.name.as_str(), member.span.start);
}
AT::ComputedMemberExpression(member) => {
if let oxc_ast::ast::Expression::StringLiteral(key) = &member.expression {
self.note_reserved(key.value.as_str(), member.span.start);
}
}
_ => {}
}
oxc_ast_visit::walk::walk_assignment_target(self, target);
}
fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
if let oxc_ast::ast::Expression::Identifier(ident) = &call.callee {
self.check_callee_name(ident.name.as_str(), ident.span.start);
}
oxc_ast_visit::walk::walk_call_expression(self, call);
}
fn visit_new_expression(&mut self, expr: &oxc_ast::ast::NewExpression<'a>) {
if let oxc_ast::ast::Expression::Identifier(ident) = &expr.callee {
self.check_callee_name(ident.name.as_str(), ident.span.start);
}
oxc_ast_visit::walk::walk_new_expression(self, expr);
}
fn visit_import_expression(&mut self, expr: &oxc_ast::ast::ImportExpression<'a>) {
if !self.policy.allow_import {
self.refuse(
"SCRIPT_IMPORT",
"dynamic `import()` is disabled".to_string(),
expr.span.start,
);
}
oxc_ast_visit::walk::walk_import_expression(self, expr);
}
fn visit_assignment_target_property_identifier(
&mut self,
prop: &oxc_ast::ast::AssignmentTargetPropertyIdentifier<'a>,
) {
self.note_reserved(prop.binding.name.as_str(), prop.binding.span.start);
oxc_ast_visit::walk::walk_assignment_target_property_identifier(self, prop);
}
fn visit_with_statement(&mut self, stmt: &oxc_ast::ast::WithStatement<'a>) {
self.refuse(
"SCRIPT_WITH_STATEMENT",
"`with` is not permitted: it rebinds every name in its body, \
including the runtime's own execution limits"
.to_string(),
stmt.span.start,
);
oxc_ast_visit::walk::walk_with_statement(self, stmt);
}
fn visit_while_statement(&mut self, stmt: &WhileStatement<'a>) {
self.guard_test(stmt.test.span().start);
oxc_ast_visit::walk::walk_while_statement(self, stmt);
}
fn visit_do_while_statement(&mut self, stmt: &DoWhileStatement<'a>) {
self.guard_test(stmt.test.span().start);
oxc_ast_visit::walk::walk_do_while_statement(self, stmt);
}
fn visit_for_statement(&mut self, stmt: &ForStatement<'a>) {
match &stmt.test {
Some(test) => self.guard_test(test.span().start),
None => self.guard_body(&stmt.body),
}
oxc_ast_visit::walk::walk_for_statement(self, stmt);
}
fn visit_for_of_statement(&mut self, stmt: &ForOfStatement<'a>) {
self.guard_body(&stmt.body);
oxc_ast_visit::walk::walk_for_of_statement(self, stmt);
}
fn visit_for_in_statement(&mut self, stmt: &ForInStatement<'a>) {
self.guard_body(&stmt.body);
oxc_ast_visit::walk::walk_for_in_statement(self, stmt);
}
}