use crate::types::{
CodeLocation, CodeType, Complexity, SecurityAnalysis, SecurityIssue, SecurityIssueType,
ValidationError,
};
use std::collections::HashSet;
use swc_common::{sync::Lrc, SourceMap, Span};
use swc_ecma_ast::*;
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax};
use swc_ecma_visit::{Visit, VisitWith};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
}
impl HttpMethod {
pub fn is_read_only(&self) -> bool {
matches!(
self,
HttpMethod::Get | HttpMethod::Head | HttpMethod::Options
)
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"get" => Some(HttpMethod::Get),
"post" => Some(HttpMethod::Post),
"put" => Some(HttpMethod::Put),
"delete" => Some(HttpMethod::Delete),
"patch" => Some(HttpMethod::Patch),
"head" => Some(HttpMethod::Head),
"options" => Some(HttpMethod::Options),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct ApiCall {
pub method: HttpMethod,
pub path: String,
pub is_dynamic_path: bool,
pub line: u32,
pub column: u32,
}
#[derive(Debug, Clone, Default)]
pub struct OutputDeclaration {
pub has_declaration: bool,
pub type_string: Option<String>,
pub declared_fields: HashSet<String>,
pub has_spread_risk: bool,
}
#[derive(Debug, Clone, Default)]
pub struct JavaScriptCodeInfo {
pub api_calls: Vec<ApiCall>,
pub is_read_only: bool,
pub endpoints_accessed: HashSet<String>,
pub methods_used: HashSet<String>,
pub uses_async: bool,
pub variable_names: Vec<String>,
pub max_depth: usize,
pub loop_count: usize,
pub all_loops_bounded: bool,
pub violations: Vec<SafetyViolation>,
pub statement_count: usize,
pub output_declaration: OutputDeclaration,
pub has_output_spread_risk: bool,
}
#[derive(Debug, Clone)]
pub struct SafetyViolation {
pub violation_type: SafetyViolationType,
pub message: String,
pub location: Option<CodeLocation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SafetyViolationType {
ImportExport,
DynamicCodeExecution,
UnboundedLoop,
FunctionDeclaration,
TryCatch,
NewKeyword,
ThisKeyword,
ClassDeclaration,
Generator,
WithStatement,
DeleteOperator,
PrototypeManipulation,
UnboundedForLoop,
UnknownApiCall,
}
pub struct JavaScriptValidator {
sensitive_paths: Vec<String>,
max_depth: usize,
max_api_calls: usize,
max_loops: usize,
max_statements: usize,
sdk_operations: HashSet<String>,
}
impl Default for JavaScriptValidator {
fn default() -> Self {
Self {
sensitive_paths: vec![
"/admin".into(),
"/internal".into(),
"/debug".into(),
"/metrics".into(),
"/health".into(),
],
max_depth: 10,
max_api_calls: 50,
max_loops: 10,
max_statements: 100,
sdk_operations: HashSet::new(),
}
}
}
fn is_type_keyword(word: &str) -> bool {
matches!(
word,
"string"
| "number"
| "boolean"
| "null"
| "undefined"
| "void"
| "any"
| "never"
| "object"
| "Array"
| "Promise"
| "Record"
| "Map"
| "Set"
| "Date"
| "type"
| "interface"
)
}
impl JavaScriptValidator {
pub fn new(
sensitive_paths: Vec<String>,
max_depth: usize,
max_api_calls: usize,
max_loops: usize,
max_statements: usize,
) -> Self {
Self {
sensitive_paths,
max_depth,
max_api_calls,
max_loops,
max_statements,
sdk_operations: HashSet::new(),
}
}
pub fn with_sdk_operations(mut self, operations: HashSet<String>) -> Self {
self.sdk_operations = operations;
self
}
fn parse_returns_annotation(code: &str) -> OutputDeclaration {
let mut declaration = OutputDeclaration::default();
for line in code.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("///") {
if let Some(returns_content) = Self::extract_returns_content(rest) {
declaration.has_declaration = true;
declaration.type_string = Some(returns_content.clone());
declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
declaration.has_spread_risk = returns_content.contains("...");
return declaration;
}
}
else if let Some(rest) = trimmed.strip_prefix("//") {
if let Some(returns_content) = Self::extract_returns_content(rest) {
declaration.has_declaration = true;
declaration.type_string = Some(returns_content.clone());
declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
declaration.has_spread_risk = returns_content.contains("...");
return declaration;
}
}
if trimmed.starts_with("/**") || trimmed.starts_with("*") {
let content = trimmed
.trim_start_matches("/**")
.trim_start_matches('*')
.trim_end_matches("*/")
.trim();
if let Some(returns_content) = Self::extract_returns_content(content) {
declaration.has_declaration = true;
declaration.type_string = Some(returns_content.clone());
declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
declaration.has_spread_risk = returns_content.contains("...");
return declaration;
}
}
}
declaration
}
fn extract_returns_content(text: &str) -> Option<String> {
let text = text.trim();
let returns_pos = text.find("@returns").or_else(|| text.find("@return"))?;
let after_tag = &text[returns_pos..];
let content_start = after_tag.find(|c: char| c == '{' || c == '(')?;
let chars: Vec<char> = after_tag[content_start..].chars().collect();
let open_char = chars[0];
let close_char = if open_char == '{' { '}' } else { ')' };
let mut depth = 0;
let mut end_pos = 0;
for (i, c) in chars.iter().enumerate() {
if *c == open_char {
depth += 1;
} else if *c == close_char {
depth -= 1;
if depth == 0 {
end_pos = i + 1;
break;
}
}
}
if end_pos > 0 {
Some(after_tag[content_start..content_start + end_pos].to_string())
} else {
Some(after_tag[content_start..].trim().to_string())
}
}
fn extract_fields_from_type(type_string: &str) -> HashSet<String> {
let mut fields = HashSet::new();
let chars: Vec<char> = type_string.chars().collect();
let mut current_word = String::new();
let mut in_word = false;
for (_i, c) in chars.iter().enumerate() {
if c.is_alphanumeric() || *c == '_' {
current_word.push(*c);
in_word = true;
} else {
if in_word && *c == ':' {
if !current_word.is_empty()
&& !is_type_keyword(¤t_word)
&& !current_word.chars().next().unwrap().is_ascii_uppercase()
{
fields.insert(current_word.clone());
}
}
current_word.clear();
in_word = false;
}
}
fields
}
pub fn check_output_against_blocklist(
declaration: &OutputDeclaration,
blocked_fields: &HashSet<String>,
) -> Vec<String> {
let mut violations = Vec::new();
for field in &declaration.declared_fields {
if blocked_fields.contains(field) {
violations.push(format!("Output declares blocked field: {}", field));
continue;
}
for blocked in blocked_fields {
if blocked.starts_with("*.") {
let pattern = &blocked[2..];
if field == pattern {
violations.push(format!(
"Output declares blocked field pattern: {}",
blocked
));
}
}
}
}
violations
}
pub fn validate(&self, code: &str) -> Result<JavaScriptCodeInfo, ValidationError> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(
swc_common::FileName::Custom("code.js".into()).into(),
code.to_string(),
);
let lexer = Lexer::new(
Syntax::Es(Default::default()),
EsVersion::Es2022,
StringInput::from(&*fm),
None,
);
let mut parser = Parser::new_from(lexer);
let module = parser
.parse_module()
.map_err(|e| ValidationError::ParseError {
message: format!("JavaScript parse error: {:?}", e.into_kind()),
line: 0,
column: 0,
})?;
let mut visitor = SafetyVisitor::new(&cm).with_sdk_operations(self.sdk_operations.clone());
module.visit_with(&mut visitor);
let mut info = visitor.into_info();
info.output_declaration = Self::parse_returns_annotation(code);
if info.api_calls.len() > self.max_api_calls {
return Err(ValidationError::SecurityError {
message: format!(
"Too many API calls: {} (max: {})",
info.api_calls.len(),
self.max_api_calls
),
issue: SecurityIssueType::HighComplexity,
});
}
if info.max_depth > self.max_depth {
return Err(ValidationError::SecurityError {
message: format!(
"Code nesting depth {} exceeds maximum {}",
info.max_depth, self.max_depth
),
issue: SecurityIssueType::DeepNesting,
});
}
if info.loop_count > self.max_loops {
return Err(ValidationError::SecurityError {
message: format!(
"Too many loops: {} (max: {})",
info.loop_count, self.max_loops
),
issue: SecurityIssueType::HighComplexity,
});
}
if info.statement_count > self.max_statements {
return Err(ValidationError::SecurityError {
message: format!(
"Too many statements: {} (max: {})",
info.statement_count, self.max_statements
),
issue: SecurityIssueType::HighComplexity,
});
}
if !info.violations.is_empty() {
let first = &info.violations[0];
return Err(ValidationError::SecurityError {
message: first.message.clone(),
issue: violation_to_security_issue(first.violation_type),
});
}
info.is_read_only = info.api_calls.iter().all(|c| c.method.is_read_only());
Ok(info)
}
pub fn analyze_security(&self, info: &JavaScriptCodeInfo) -> SecurityAnalysis {
let mut analysis = SecurityAnalysis {
is_read_only: info.is_read_only,
tables_accessed: info.endpoints_accessed.clone(),
fields_accessed: HashSet::new(),
has_aggregation: false,
has_subqueries: info.max_depth > 3,
estimated_complexity: self.estimate_complexity(info),
potential_issues: Vec::new(),
estimated_rows: None,
};
for endpoint in &info.endpoints_accessed {
let endpoint_lower = endpoint.to_lowercase();
if self
.sensitive_paths
.iter()
.any(|s| endpoint_lower.contains(&s.to_lowercase()))
{
analysis.potential_issues.push(SecurityIssue::new(
SecurityIssueType::SensitiveFields,
format!("Code accesses potentially sensitive endpoint: {}", endpoint),
));
}
}
for call in &info.api_calls {
if call.is_dynamic_path {
analysis.potential_issues.push(
SecurityIssue::new(
SecurityIssueType::DynamicTableName,
format!(
"API call at line {} uses dynamic path interpolation",
call.line
),
)
.with_location(CodeLocation {
line: call.line,
column: call.column,
}),
);
}
}
if info.max_depth > 5 {
analysis.potential_issues.push(SecurityIssue::new(
SecurityIssueType::DeepNesting,
format!("Code has deep nesting (depth: {})", info.max_depth),
));
}
if !info.all_loops_bounded && info.loop_count > 0 {
analysis.potential_issues.push(SecurityIssue::new(
SecurityIssueType::UnboundedQuery,
"Code contains for...of loops without .slice() bounds",
));
}
if matches!(analysis.estimated_complexity, Complexity::High) {
analysis.potential_issues.push(SecurityIssue::new(
SecurityIssueType::HighComplexity,
"Code has high complexity",
));
}
analysis
}
fn estimate_complexity(&self, info: &JavaScriptCodeInfo) -> Complexity {
let api_count = info.api_calls.len();
let loop_count = info.loop_count;
let depth = info.max_depth;
let statement_count = info.statement_count;
let complexity_score = api_count * 3 + loop_count * 5 + depth * 2 + statement_count;
if complexity_score > 100 {
Complexity::High
} else if complexity_score > 50 {
Complexity::Medium
} else {
Complexity::Low
}
}
pub fn to_code_type(&self, info: &JavaScriptCodeInfo) -> CodeType {
if info.is_read_only {
CodeType::RestGet
} else {
CodeType::RestMutation
}
}
}
struct SafetyVisitor {
source_map: Lrc<SourceMap>,
api_calls: Vec<ApiCall>,
violations: Vec<SafetyViolation>,
variable_names: Vec<String>,
endpoints_accessed: HashSet<String>,
methods_used: HashSet<String>,
uses_async: bool,
current_depth: usize,
max_depth: usize,
loop_count: usize,
bounded_loops: usize,
statement_count: usize,
has_spread_in_return: bool,
in_return_context: bool,
sdk_operations: HashSet<String>,
}
impl SafetyVisitor {
fn new(source_map: &Lrc<SourceMap>) -> Self {
Self {
source_map: source_map.clone(),
api_calls: Vec::new(),
violations: Vec::new(),
variable_names: Vec::new(),
endpoints_accessed: HashSet::new(),
methods_used: HashSet::new(),
uses_async: false,
current_depth: 0,
max_depth: 0,
loop_count: 0,
bounded_loops: 0,
statement_count: 0,
has_spread_in_return: false,
in_return_context: false,
sdk_operations: HashSet::new(),
}
}
fn with_sdk_operations(mut self, operations: HashSet<String>) -> Self {
self.sdk_operations = operations;
self
}
fn into_info(self) -> JavaScriptCodeInfo {
JavaScriptCodeInfo {
api_calls: self.api_calls,
is_read_only: false, endpoints_accessed: self.endpoints_accessed,
methods_used: self.methods_used,
uses_async: self.uses_async,
variable_names: self.variable_names,
max_depth: self.max_depth,
loop_count: self.loop_count,
all_loops_bounded: self.loop_count == 0 || self.bounded_loops == self.loop_count,
violations: self.violations,
statement_count: self.statement_count,
output_declaration: OutputDeclaration::default(), has_output_spread_risk: self.has_spread_in_return,
}
}
fn span_to_location(&self, span: Span) -> CodeLocation {
let loc = self.source_map.lookup_char_pos(span.lo);
CodeLocation {
line: loc.line as u32,
column: loc.col_display as u32,
}
}
fn add_violation(&mut self, violation_type: SafetyViolationType, message: &str, span: Span) {
self.violations.push(SafetyViolation {
violation_type,
message: message.into(),
location: Some(self.span_to_location(span)),
});
}
fn check_api_call(&mut self, call: &CallExpr) {
if let Callee::Expr(expr) = &call.callee {
if let Expr::Member(member) = &**expr {
if let Expr::Ident(obj) = &*member.obj {
if obj.sym.as_ref() == "api" {
if let MemberProp::Ident(method_ident) = &member.prop {
let method_name = method_ident.sym.as_ref();
if !self.sdk_operations.is_empty() {
if self.sdk_operations.contains(method_name) {
self.methods_used.insert(method_name.to_string());
self.endpoints_accessed
.insert(format!("sdk:{}", method_name));
} else {
self.add_violation(
SafetyViolationType::UnknownApiCall,
&format!(
"Unknown SDK operation: api.{}(). Check the code mode schema resource for available operations.",
method_name
),
call.span,
);
}
return;
}
if let Some(method) = HttpMethod::from_str(method_name) {
self.methods_used.insert(method_name.to_uppercase());
let (path, is_dynamic) = if let Some(arg) = call.args.first() {
self.extract_path(&arg.expr)
} else {
("unknown".into(), false)
};
self.endpoints_accessed.insert(path.clone());
let loc = self.span_to_location(call.span);
self.api_calls.push(ApiCall {
method,
path,
is_dynamic_path: is_dynamic,
line: loc.line,
column: loc.column,
});
} else {
self.add_violation(
SafetyViolationType::UnknownApiCall,
&format!("Unknown api method: api.{}()", method_name),
call.span,
);
}
}
}
}
}
}
}
fn extract_path(&self, expr: &Expr) -> (String, bool) {
match expr {
Expr::Lit(Lit::Str(s)) => {
(s.value.to_string_lossy().into_owned(), false)
},
Expr::Tpl(tpl) => {
let mut path = String::new();
for quasi in &tpl.quasis {
path.push_str(&quasi.raw.to_string());
if !tpl.exprs.is_empty() {
path.push_str("{...}");
}
}
(path, !tpl.exprs.is_empty())
},
_ => ("dynamic".into(), true),
}
}
fn check_for_bounded(&mut self, for_of: &ForOfStmt) -> bool {
if let Expr::Call(call) = &*for_of.right {
if let Callee::Expr(callee) = &call.callee {
if let Expr::Member(member) = &**callee {
if let MemberProp::Ident(ident) = &member.prop {
if ident.sym.as_ref() == "slice" {
return true;
}
}
}
}
}
false
}
}
impl Visit for SafetyVisitor {
fn visit_block_stmt(&mut self, n: &BlockStmt) {
self.current_depth += 1;
self.max_depth = self.max_depth.max(self.current_depth);
n.visit_children_with(self);
self.current_depth -= 1;
}
fn visit_stmt(&mut self, n: &Stmt) {
self.statement_count += 1;
n.visit_children_with(self);
}
fn visit_import_decl(&mut self, n: &ImportDecl) {
self.add_violation(
SafetyViolationType::ImportExport,
"import statements are not allowed",
n.span,
);
}
fn visit_export_decl(&mut self, n: &ExportDecl) {
self.add_violation(
SafetyViolationType::ImportExport,
"export statements are not allowed",
n.span,
);
}
fn visit_export_default_decl(&mut self, n: &ExportDefaultDecl) {
self.add_violation(
SafetyViolationType::ImportExport,
"export default is not allowed",
n.span,
);
}
fn visit_export_default_expr(&mut self, n: &ExportDefaultExpr) {
self.add_violation(
SafetyViolationType::ImportExport,
"export default is not allowed",
n.span,
);
}
fn visit_call_expr(&mut self, n: &CallExpr) {
if let Callee::Expr(callee) = &n.callee {
if let Expr::Ident(ident) = &**callee {
let name = ident.sym.as_ref();
if name == "eval" || name == "Function" {
self.add_violation(
SafetyViolationType::DynamicCodeExecution,
&format!("{}() is not allowed", name),
n.span,
);
}
}
}
self.check_api_call(n);
n.visit_children_with(self);
}
fn visit_while_stmt(&mut self, n: &WhileStmt) {
self.add_violation(
SafetyViolationType::UnboundedLoop,
"while loops are not allowed (use bounded for...of with .slice())",
n.span,
);
n.visit_children_with(self);
}
fn visit_do_while_stmt(&mut self, n: &DoWhileStmt) {
self.add_violation(
SafetyViolationType::UnboundedLoop,
"do-while loops are not allowed (use bounded for...of with .slice())",
n.span,
);
n.visit_children_with(self);
}
fn visit_for_of_stmt(&mut self, n: &ForOfStmt) {
self.loop_count += 1;
if self.check_for_bounded(n) {
self.bounded_loops += 1;
}
n.visit_children_with(self);
}
fn visit_for_stmt(&mut self, n: &ForStmt) {
self.loop_count += 1;
self.bounded_loops += 1;
n.visit_children_with(self);
}
fn visit_fn_decl(&mut self, n: &FnDecl) {
self.add_violation(
SafetyViolationType::FunctionDeclaration,
"function declarations are not allowed (use arrow functions)",
n.function.span,
);
n.visit_children_with(self);
}
fn visit_try_stmt(&mut self, n: &TryStmt) {
n.visit_children_with(self);
}
fn visit_new_expr(&mut self, n: &NewExpr) {
let allowed = if let Expr::Ident(ident) = &*n.callee {
matches!(
ident.sym.as_ref(),
"Date" | "URL" | "URLSearchParams" | "Map" | "Set" | "Array"
)
} else {
false
};
if !allowed {
self.add_violation(
SafetyViolationType::NewKeyword,
"new keyword is only allowed for Date, URL, URLSearchParams, Map, Set, Array",
n.span,
);
}
n.visit_children_with(self);
}
fn visit_this_expr(&mut self, n: &ThisExpr) {
self.add_violation(
SafetyViolationType::ThisKeyword,
"'this' keyword is not allowed",
n.span,
);
}
fn visit_class_decl(&mut self, n: &ClassDecl) {
self.add_violation(
SafetyViolationType::ClassDeclaration,
"class declarations are not allowed",
n.class.span,
);
n.visit_children_with(self);
}
fn visit_with_stmt(&mut self, n: &WithStmt) {
self.add_violation(
SafetyViolationType::WithStatement,
"'with' statement is not allowed",
n.span,
);
n.visit_children_with(self);
}
fn visit_await_expr(&mut self, n: &AwaitExpr) {
self.uses_async = true;
n.visit_children_with(self);
}
fn visit_var_decl(&mut self, n: &VarDecl) {
for decl in &n.decls {
if let Pat::Ident(ident) = &decl.name {
self.variable_names.push(ident.id.sym.to_string());
}
}
n.visit_children_with(self);
}
fn visit_function(&mut self, n: &Function) {
if n.is_generator {
self.add_violation(
SafetyViolationType::Generator,
"generator functions are not allowed",
n.span,
);
}
n.visit_children_with(self);
}
fn visit_unary_expr(&mut self, n: &UnaryExpr) {
if n.op == UnaryOp::Delete {
self.add_violation(
SafetyViolationType::DeleteOperator,
"'delete' operator is not allowed",
n.span,
);
}
n.visit_children_with(self);
}
fn visit_member_expr(&mut self, n: &MemberExpr) {
if let MemberProp::Ident(ident) = &n.prop {
let name = ident.sym.as_ref();
if name == "__proto__" || name == "prototype" {
self.add_violation(
SafetyViolationType::PrototypeManipulation,
"prototype manipulation is not allowed",
n.span,
);
}
}
n.visit_children_with(self);
}
fn visit_return_stmt(&mut self, n: &ReturnStmt) {
self.in_return_context = true;
n.visit_children_with(self);
self.in_return_context = false;
}
fn visit_spread_element(&mut self, n: &SpreadElement) {
if self.in_return_context {
self.has_spread_in_return = true;
}
n.visit_children_with(self);
}
}
fn violation_to_security_issue(violation: SafetyViolationType) -> SecurityIssueType {
match violation {
SafetyViolationType::DynamicCodeExecution => SecurityIssueType::PotentialInjection,
SafetyViolationType::PrototypeManipulation => SecurityIssueType::PotentialInjection,
SafetyViolationType::UnboundedLoop | SafetyViolationType::UnboundedForLoop => {
SecurityIssueType::UnboundedQuery
},
_ => SecurityIssueType::HighComplexity,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_api_call() {
let validator = JavaScriptValidator::default();
let code = r#"
const response = await api.get("/users");
return response.data;
"#;
let info = validator.validate(code).unwrap();
assert!(info.is_read_only);
assert_eq!(info.api_calls.len(), 1);
assert_eq!(info.api_calls[0].method, HttpMethod::Get);
assert!(info.endpoints_accessed.contains("/users"));
}
#[test]
fn test_multiple_api_calls() {
let validator = JavaScriptValidator::default();
let code = r#"
const user = await api.get("/users/123");
const orders = await api.get(`/users/${user.id}/orders`);
return { user, orders };
"#;
let info = validator.validate(code).unwrap();
assert!(info.is_read_only);
assert_eq!(info.api_calls.len(), 2);
assert!(info.api_calls[1].is_dynamic_path);
}
#[test]
fn test_mutation_detection() {
let validator = JavaScriptValidator::default();
let code = r#"
const result = await api.post("/users", { name: "test" });
return result;
"#;
let info = validator.validate(code).unwrap();
assert!(!info.is_read_only);
assert_eq!(info.api_calls[0].method, HttpMethod::Post);
}
#[test]
fn test_reject_eval() {
let validator = JavaScriptValidator::default();
let code = r#"
const result = eval("api.get('/users')");
"#;
let result = validator.validate(code);
assert!(result.is_err());
}
#[test]
fn test_reject_while_loop() {
let validator = JavaScriptValidator::default();
let code = r#"
let i = 0;
while (i < 10) {
await api.get("/data");
i++;
}
"#;
let result = validator.validate(code);
assert!(result.is_err());
}
#[test]
fn test_allow_bounded_for_of() {
let validator = JavaScriptValidator::default();
let code = r#"
const results = [];
for (const id of userIds.slice(0, 10)) {
const user = await api.get(`/users/${id}`);
results.push(user);
}
return results;
"#;
let info = validator.validate(code).unwrap();
assert!(info.all_loops_bounded);
assert_eq!(info.loop_count, 1);
}
#[test]
fn test_reject_import() {
let validator = JavaScriptValidator::default();
let code = r#"
import axios from 'axios';
const result = await api.get("/users");
"#;
let result = validator.validate(code);
assert!(result.is_err());
}
#[test]
fn test_allow_arrow_functions() {
let validator = JavaScriptValidator::default();
let code = r#"
const users = await api.get("/users");
const names = users.data.map(u => u.name);
return names;
"#;
let info = validator.validate(code).unwrap();
assert!(info.violations.is_empty());
}
#[test]
fn test_reject_function_declaration() {
let validator = JavaScriptValidator::default();
let code = r#"
function fetchUser(id) {
return api.get(`/users/${id}`);
}
"#;
let result = validator.validate(code);
assert!(result.is_err());
}
#[test]
fn test_security_analysis_sensitive_endpoint() {
let validator = JavaScriptValidator::default();
let code = r#"
const config = await api.get("/admin/config");
return config;
"#;
let info = validator.validate(code).unwrap();
let analysis = validator.analyze_security(&info);
assert!(analysis
.potential_issues
.iter()
.any(|i| matches!(i.issue_type, SecurityIssueType::SensitiveFields)));
}
#[test]
fn test_parse_returns_annotation_triple_slash() {
let validator = JavaScriptValidator::default();
let code = r#"
/// @returns { users: Array<{ id: string, name: string }> }
const users = await api.get("/users");
return { users: users.map(u => ({ id: u.id, name: u.name })) };
"#;
let info = validator.validate(code).unwrap();
assert!(info.output_declaration.has_declaration);
assert!(info.output_declaration.declared_fields.contains("id"));
assert!(info.output_declaration.declared_fields.contains("name"));
assert!(info.output_declaration.declared_fields.contains("users"));
}
#[test]
fn test_parse_returns_annotation_double_slash() {
let validator = JavaScriptValidator::default();
let code = r#"
// @returns { products: Array<{ id: string, name: string, price: number }> }
const products = await api.get("/products");
return { products: products.map(p => ({ id: p.id, name: p.name, price: p.price })) };
"#;
let info = validator.validate(code).unwrap();
assert!(info.output_declaration.has_declaration);
assert!(info.output_declaration.declared_fields.contains("id"));
assert!(info.output_declaration.declared_fields.contains("name"));
assert!(info.output_declaration.declared_fields.contains("price"));
assert!(info.output_declaration.declared_fields.contains("products"));
}
#[test]
fn test_parse_returns_annotation_jsdoc() {
let validator = JavaScriptValidator::default();
let code = r#"
/** @returns { user: { id: string, email: string } } */
const user = await api.get("/users/123");
return { user: { id: user.id, email: user.email } };
"#;
let info = validator.validate(code).unwrap();
assert!(info.output_declaration.has_declaration);
assert!(info.output_declaration.declared_fields.contains("id"));
assert!(info.output_declaration.declared_fields.contains("email"));
assert!(info.output_declaration.declared_fields.contains("user"));
}
#[test]
fn test_no_returns_annotation() {
let validator = JavaScriptValidator::default();
let code = r#"
const users = await api.get("/users");
return users;
"#;
let info = validator.validate(code).unwrap();
assert!(!info.output_declaration.has_declaration);
assert!(info.output_declaration.declared_fields.is_empty());
}
#[test]
fn test_spread_operator_detection() {
let validator = JavaScriptValidator::default();
let code = r#"
const user = await api.get("/users/123");
return { ...user, computed: "value" };
"#;
let info = validator.validate(code).unwrap();
assert!(info.has_output_spread_risk);
}
#[test]
fn test_no_spread_operator_in_return() {
let validator = JavaScriptValidator::default();
let code = r#"
const user = await api.get("/users/123");
return { id: user.id, name: user.name };
"#;
let info = validator.validate(code).unwrap();
assert!(!info.has_output_spread_risk);
}
#[test]
fn test_check_output_against_blocklist() {
let declaration = OutputDeclaration {
has_declaration: true,
type_string: Some("{ id: string, ssn: string }".to_string()),
declared_fields: ["id", "ssn"].iter().map(|s| s.to_string()).collect(),
has_spread_risk: false,
};
let blocked_fields: HashSet<String> =
["ssn", "password"].iter().map(|s| s.to_string()).collect();
let violations =
JavaScriptValidator::check_output_against_blocklist(&declaration, &blocked_fields);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("ssn"));
}
#[test]
fn test_check_output_against_wildcard_blocklist() {
let declaration = OutputDeclaration {
has_declaration: true,
type_string: Some("{ user: { id: string, salary: number } }".to_string()),
declared_fields: ["user", "id", "salary"]
.iter()
.map(|s| s.to_string())
.collect(),
has_spread_risk: false,
};
let blocked_fields: HashSet<String> = ["*.salary"].iter().map(|s| s.to_string()).collect();
let violations =
JavaScriptValidator::check_output_against_blocklist(&declaration, &blocked_fields);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("salary"));
}
}