Skip to main content

pmcp_code_mode/
javascript.rs

1//! JavaScript-specific validation for Code Mode (OpenAPI servers).
2//!
3//! This module validates JavaScript code generated by LLMs for REST API interactions.
4//! It uses SWC (Speedy Web Compiler) for production-grade parsing and enforces a safe
5//! subset of JavaScript that prevents malicious operations while enabling powerful
6//! API orchestration.
7//!
8//! ## Safe Subset
9//!
10//! Allowed:
11//! - async/await for API calls
12//! - api.get(), api.post(), api.put(), api.delete(), api.patch() calls
13//! - const/let variable declarations
14//! - Arrow functions for callbacks (including block bodies with nested callbacks)
15//! - Array methods: map, filter, reduce, find, some, every, slice
16//! - Object destructuring and spread
17//! - Template literals (string interpolation)
18//! - Bounded for...of loops (with .slice() limits)
19//! - if/else conditionals
20//! - try/catch for error handling
21//! - Logical operators (&&, ||)
22//!
23//! Blocked:
24//! - import/export statements
25//! - eval(), Function(), new Function()
26//! - while/do-while loops (unbounded)
27//! - Regular function declarations (only arrow functions)
28//! - new keyword (except specific built-ins)
29//! - this keyword
30//! - class declarations
31//! - Generators/iterators
32//! - with statement
33//! - delete operator
34//! - Prototype manipulation
35
36use crate::types::{
37    CodeLocation, CodeType, Complexity, SecurityAnalysis, SecurityIssue, SecurityIssueType,
38    ValidationError,
39};
40use std::collections::HashSet;
41use swc_common::{sync::Lrc, SourceMap, Span};
42use swc_ecma_ast::*;
43use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax};
44use swc_ecma_visit::{Visit, VisitWith};
45
46/// HTTP methods that can be called via the api object.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum HttpMethod {
49    Get,
50    Post,
51    Put,
52    Delete,
53    Patch,
54    Head,
55    Options,
56}
57
58impl HttpMethod {
59    /// Whether this method is read-only (safe).
60    pub fn is_read_only(&self) -> bool {
61        matches!(
62            self,
63            HttpMethod::Get | HttpMethod::Head | HttpMethod::Options
64        )
65    }
66
67    /// Parse from string.
68    pub fn from_str(s: &str) -> Option<Self> {
69        match s.to_lowercase().as_str() {
70            "get" => Some(HttpMethod::Get),
71            "post" => Some(HttpMethod::Post),
72            "put" => Some(HttpMethod::Put),
73            "delete" => Some(HttpMethod::Delete),
74            "patch" => Some(HttpMethod::Patch),
75            "head" => Some(HttpMethod::Head),
76            "options" => Some(HttpMethod::Options),
77            _ => None,
78        }
79    }
80}
81
82/// An API call extracted from the JavaScript code.
83#[derive(Debug, Clone)]
84pub struct ApiCall {
85    /// The HTTP method
86    pub method: HttpMethod,
87    /// The path template (may contain interpolations)
88    pub path: String,
89    /// Whether the path is dynamic (contains template expressions)
90    pub is_dynamic_path: bool,
91    /// Line number in the source
92    pub line: u32,
93    /// Column number in the source
94    pub column: u32,
95}
96
97/// Declared output type from @returns annotation.
98#[derive(Debug, Clone, Default)]
99pub struct OutputDeclaration {
100    /// Whether a @returns annotation was found
101    pub has_declaration: bool,
102
103    /// The raw type string from the annotation (e.g., "{ users: Array<{ id: string, name: string }> }")
104    pub type_string: Option<String>,
105
106    /// Fields mentioned in the output type (extracted for field blocklist checking)
107    pub declared_fields: HashSet<String>,
108
109    /// Whether the declaration uses spread operators (potential field leakage)
110    pub has_spread_risk: bool,
111}
112
113/// Information extracted from parsed JavaScript code.
114#[derive(Debug, Clone, Default)]
115pub struct JavaScriptCodeInfo {
116    /// All API calls in the code
117    pub api_calls: Vec<ApiCall>,
118
119    /// Whether the code is read-only (only GET/HEAD/OPTIONS calls)
120    pub is_read_only: bool,
121
122    /// All endpoints accessed
123    pub endpoints_accessed: HashSet<String>,
124
125    /// All HTTP methods used
126    pub methods_used: HashSet<String>,
127
128    /// Whether the code uses async/await
129    pub uses_async: bool,
130
131    /// Variable names declared
132    pub variable_names: Vec<String>,
133
134    /// Maximum nesting depth
135    pub max_depth: usize,
136
137    /// Number of for...of loops
138    pub loop_count: usize,
139
140    /// Whether all loops are bounded (use .slice())
141    pub all_loops_bounded: bool,
142
143    /// Policy violations found during parsing
144    pub violations: Vec<SafetyViolation>,
145
146    /// Total number of statements
147    pub statement_count: usize,
148
149    /// Output declaration from @returns annotation
150    pub output_declaration: OutputDeclaration,
151
152    /// Whether the script contains spread operators that could leak fields
153    pub has_output_spread_risk: bool,
154}
155
156/// A safety violation found during JavaScript validation.
157#[derive(Debug, Clone)]
158pub struct SafetyViolation {
159    /// Type of violation
160    pub violation_type: SafetyViolationType,
161    /// Human-readable message
162    pub message: String,
163    /// Location in source
164    pub location: Option<CodeLocation>,
165}
166
167/// Types of safety violations in JavaScript code.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum SafetyViolationType {
170    /// import/export statement
171    ImportExport,
172    /// eval() or Function() call
173    DynamicCodeExecution,
174    /// while/do-while loop (unbounded)
175    UnboundedLoop,
176    /// Regular function declaration
177    FunctionDeclaration,
178    /// try/catch statement
179    TryCatch,
180    /// new keyword (except allowed)
181    NewKeyword,
182    /// this keyword
183    ThisKeyword,
184    /// class declaration
185    ClassDeclaration,
186    /// Generator function
187    Generator,
188    /// with statement
189    WithStatement,
190    /// delete operator
191    DeleteOperator,
192    /// Prototype manipulation
193    PrototypeManipulation,
194    /// Unbounded for loop (no .slice())
195    UnboundedForLoop,
196    /// Unknown API call (not api.method())
197    UnknownApiCall,
198}
199
200/// JavaScript code validator for OpenAPI Code Mode.
201pub struct JavaScriptValidator {
202    /// Sensitive path patterns (e.g., "/admin", "/internal")
203    sensitive_paths: Vec<String>,
204
205    /// Maximum allowed nesting depth
206    max_depth: usize,
207
208    /// Maximum allowed API calls
209    max_api_calls: usize,
210
211    /// Maximum allowed loop count
212    max_loops: usize,
213
214    /// Maximum allowed statements
215    max_statements: usize,
216
217    /// Allowed SDK operation names (camelCase). When non-empty, SDK mode is active:
218    /// known operations are accepted, unknown ones generate UnknownApiCall violations.
219    sdk_operations: HashSet<String>,
220}
221
222impl Default for JavaScriptValidator {
223    fn default() -> Self {
224        Self {
225            sensitive_paths: vec![
226                "/admin".into(),
227                "/internal".into(),
228                "/debug".into(),
229                "/metrics".into(),
230                "/health".into(),
231            ],
232            max_depth: 10,
233            max_api_calls: 50,
234            max_loops: 10,
235            max_statements: 100,
236            sdk_operations: HashSet::new(),
237        }
238    }
239}
240
241/// Check if a word is a TypeScript/JSDoc type keyword (not a field name).
242fn is_type_keyword(word: &str) -> bool {
243    matches!(
244        word,
245        "string"
246            | "number"
247            | "boolean"
248            | "null"
249            | "undefined"
250            | "void"
251            | "any"
252            | "never"
253            | "object"
254            | "Array"
255            | "Promise"
256            | "Record"
257            | "Map"
258            | "Set"
259            | "Date"
260            | "type"
261            | "interface"
262    )
263}
264
265impl JavaScriptValidator {
266    /// Create a new validator with custom settings.
267    pub fn new(
268        sensitive_paths: Vec<String>,
269        max_depth: usize,
270        max_api_calls: usize,
271        max_loops: usize,
272        max_statements: usize,
273    ) -> Self {
274        Self {
275            sensitive_paths,
276            max_depth,
277            max_api_calls,
278            max_loops,
279            max_statements,
280            sdk_operations: HashSet::new(),
281        }
282    }
283
284    /// Set the allowed SDK operation names. When non-empty, the validator operates in SDK mode:
285    /// known SDK operations are accepted, unknown ones generate `UnknownApiCall` violations.
286    pub fn with_sdk_operations(mut self, operations: HashSet<String>) -> Self {
287        self.sdk_operations = operations;
288        self
289    }
290
291    /// Parse @returns annotation from code comments.
292    ///
293    /// Supports formats:
294    /// - `// @returns { type }` (single-line comment)
295    /// - `/// @returns { type }` (triple-slash comment)
296    /// - `/** @returns { type } */` (JSDoc comment)
297    fn parse_returns_annotation(code: &str) -> OutputDeclaration {
298        let mut declaration = OutputDeclaration::default();
299
300        // Try to find @returns annotation in comments
301        for line in code.lines() {
302            let trimmed = line.trim();
303
304            // Check for triple-slash comment: /// @returns { ... }
305            // Must check before double-slash since /// starts with //
306            if let Some(rest) = trimmed.strip_prefix("///") {
307                if let Some(returns_content) = Self::extract_returns_content(rest) {
308                    declaration.has_declaration = true;
309                    declaration.type_string = Some(returns_content.clone());
310                    declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
311                    declaration.has_spread_risk = returns_content.contains("...");
312                    return declaration;
313                }
314            }
315            // Check for double-slash comment: // @returns { ... }
316            else if let Some(rest) = trimmed.strip_prefix("//") {
317                if let Some(returns_content) = Self::extract_returns_content(rest) {
318                    declaration.has_declaration = true;
319                    declaration.type_string = Some(returns_content.clone());
320                    declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
321                    declaration.has_spread_risk = returns_content.contains("...");
322                    return declaration;
323                }
324            }
325
326            // Check for JSDoc comment start: /** @returns { ... } */ or /** ... @returns ... */
327            if trimmed.starts_with("/**") || trimmed.starts_with("*") {
328                let content = trimmed
329                    .trim_start_matches("/**")
330                    .trim_start_matches('*')
331                    .trim_end_matches("*/")
332                    .trim();
333
334                if let Some(returns_content) = Self::extract_returns_content(content) {
335                    declaration.has_declaration = true;
336                    declaration.type_string = Some(returns_content.clone());
337                    declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
338                    declaration.has_spread_risk = returns_content.contains("...");
339                    return declaration;
340                }
341            }
342        }
343
344        declaration
345    }
346
347    /// Extract the content after @returns.
348    fn extract_returns_content(text: &str) -> Option<String> {
349        let text = text.trim();
350
351        // Look for @returns or @return
352        let returns_pos = text.find("@returns").or_else(|| text.find("@return"))?;
353
354        // Extract everything after the tag
355        let after_tag = &text[returns_pos..];
356        let content_start = after_tag.find(|c: char| c == '{' || c == '(')?;
357
358        // Find the matching closing bracket
359        let chars: Vec<char> = after_tag[content_start..].chars().collect();
360        let open_char = chars[0];
361        let close_char = if open_char == '{' { '}' } else { ')' };
362
363        let mut depth = 0;
364        let mut end_pos = 0;
365
366        for (i, c) in chars.iter().enumerate() {
367            if *c == open_char {
368                depth += 1;
369            } else if *c == close_char {
370                depth -= 1;
371                if depth == 0 {
372                    end_pos = i + 1;
373                    break;
374                }
375            }
376        }
377
378        if end_pos > 0 {
379            Some(after_tag[content_start..content_start + end_pos].to_string())
380        } else {
381            // If no closing bracket found, take the rest of the line
382            Some(after_tag[content_start..].trim().to_string())
383        }
384    }
385
386    /// Extract field names from a type declaration string.
387    ///
388    /// This is a simple parser that extracts identifiers that appear
389    /// before colons, which are typically field names in object types.
390    fn extract_fields_from_type(type_string: &str) -> HashSet<String> {
391        let mut fields = HashSet::new();
392
393        // Simple regex-free parser: find "fieldName:" patterns
394        let chars: Vec<char> = type_string.chars().collect();
395        let mut current_word = String::new();
396        let mut in_word = false;
397
398        for (_i, c) in chars.iter().enumerate() {
399            if c.is_alphanumeric() || *c == '_' {
400                current_word.push(*c);
401                in_word = true;
402            } else {
403                if in_word && *c == ':' {
404                    // This is a field name
405                    if !current_word.is_empty()
406                        && !is_type_keyword(&current_word)
407                        && !current_word.chars().next().unwrap().is_ascii_uppercase()
408                    {
409                        fields.insert(current_word.clone());
410                    }
411                }
412                current_word.clear();
413                in_word = false;
414            }
415        }
416
417        fields
418    }
419
420    /// Check if declared output fields contain any blocked fields.
421    pub fn check_output_against_blocklist(
422        declaration: &OutputDeclaration,
423        blocked_fields: &HashSet<String>,
424    ) -> Vec<String> {
425        let mut violations = Vec::new();
426
427        for field in &declaration.declared_fields {
428            // Check exact match
429            if blocked_fields.contains(field) {
430                violations.push(format!("Output declares blocked field: {}", field));
431                continue;
432            }
433
434            // Check wildcard patterns like *.fieldName
435            for blocked in blocked_fields {
436                if blocked.starts_with("*.") {
437                    let pattern = &blocked[2..];
438                    if field == pattern {
439                        violations.push(format!(
440                            "Output declares blocked field pattern: {}",
441                            blocked
442                        ));
443                    }
444                }
445            }
446        }
447
448        violations
449    }
450
451    /// Parse and validate JavaScript code.
452    pub fn validate(&self, code: &str) -> Result<JavaScriptCodeInfo, ValidationError> {
453        // Parse the code using SWC
454        let cm: Lrc<SourceMap> = Default::default();
455
456        // Create a source file from the code - BytesStr accepts String
457        let fm = cm.new_source_file(
458            swc_common::FileName::Custom("code.js".into()).into(),
459            code.to_string(),
460        );
461
462        let lexer = Lexer::new(
463            Syntax::Es(Default::default()),
464            EsVersion::Es2022,
465            StringInput::from(&*fm),
466            None,
467        );
468
469        let mut parser = Parser::new_from(lexer);
470
471        let module = parser
472            .parse_module()
473            .map_err(|e| ValidationError::ParseError {
474                message: format!("JavaScript parse error: {:?}", e.into_kind()),
475                line: 0,
476                column: 0,
477            })?;
478
479        // Visit the AST to extract information and check safety
480        let mut visitor = SafetyVisitor::new(&cm).with_sdk_operations(self.sdk_operations.clone());
481        module.visit_with(&mut visitor);
482
483        let mut info = visitor.into_info();
484
485        // Parse @returns annotation from code comments
486        info.output_declaration = Self::parse_returns_annotation(code);
487
488        // Validate constraints
489        if info.api_calls.len() > self.max_api_calls {
490            return Err(ValidationError::SecurityError {
491                message: format!(
492                    "Too many API calls: {} (max: {})",
493                    info.api_calls.len(),
494                    self.max_api_calls
495                ),
496                issue: SecurityIssueType::HighComplexity,
497            });
498        }
499
500        if info.max_depth > self.max_depth {
501            return Err(ValidationError::SecurityError {
502                message: format!(
503                    "Code nesting depth {} exceeds maximum {}",
504                    info.max_depth, self.max_depth
505                ),
506                issue: SecurityIssueType::DeepNesting,
507            });
508        }
509
510        if info.loop_count > self.max_loops {
511            return Err(ValidationError::SecurityError {
512                message: format!(
513                    "Too many loops: {} (max: {})",
514                    info.loop_count, self.max_loops
515                ),
516                issue: SecurityIssueType::HighComplexity,
517            });
518        }
519
520        if info.statement_count > self.max_statements {
521            return Err(ValidationError::SecurityError {
522                message: format!(
523                    "Too many statements: {} (max: {})",
524                    info.statement_count, self.max_statements
525                ),
526                issue: SecurityIssueType::HighComplexity,
527            });
528        }
529
530        // Check for safety violations
531        if !info.violations.is_empty() {
532            let first = &info.violations[0];
533            return Err(ValidationError::SecurityError {
534                message: first.message.clone(),
535                issue: violation_to_security_issue(first.violation_type),
536            });
537        }
538
539        // Determine if read-only based on API calls
540        info.is_read_only = info.api_calls.iter().all(|c| c.method.is_read_only());
541
542        Ok(info)
543    }
544
545    /// Perform security analysis on code info.
546    pub fn analyze_security(&self, info: &JavaScriptCodeInfo) -> SecurityAnalysis {
547        let mut analysis = SecurityAnalysis {
548            is_read_only: info.is_read_only,
549            tables_accessed: info.endpoints_accessed.clone(),
550            fields_accessed: HashSet::new(),
551            has_aggregation: false,
552            has_subqueries: info.max_depth > 3,
553            estimated_complexity: self.estimate_complexity(info),
554            potential_issues: Vec::new(),
555            estimated_rows: None,
556        };
557
558        // Check for sensitive endpoints
559        for endpoint in &info.endpoints_accessed {
560            let endpoint_lower = endpoint.to_lowercase();
561            if self
562                .sensitive_paths
563                .iter()
564                .any(|s| endpoint_lower.contains(&s.to_lowercase()))
565            {
566                analysis.potential_issues.push(SecurityIssue::new(
567                    SecurityIssueType::SensitiveFields,
568                    format!("Code accesses potentially sensitive endpoint: {}", endpoint),
569                ));
570            }
571        }
572
573        // Check for dynamic paths (potential injection)
574        for call in &info.api_calls {
575            if call.is_dynamic_path {
576                analysis.potential_issues.push(
577                    SecurityIssue::new(
578                        SecurityIssueType::DynamicTableName,
579                        format!(
580                            "API call at line {} uses dynamic path interpolation",
581                            call.line
582                        ),
583                    )
584                    .with_location(CodeLocation {
585                        line: call.line,
586                        column: call.column,
587                    }),
588                );
589            }
590        }
591
592        // Check for deep nesting
593        if info.max_depth > 5 {
594            analysis.potential_issues.push(SecurityIssue::new(
595                SecurityIssueType::DeepNesting,
596                format!("Code has deep nesting (depth: {})", info.max_depth),
597            ));
598        }
599
600        // Check for unbounded loops
601        if !info.all_loops_bounded && info.loop_count > 0 {
602            analysis.potential_issues.push(SecurityIssue::new(
603                SecurityIssueType::UnboundedQuery,
604                "Code contains for...of loops without .slice() bounds",
605            ));
606        }
607
608        // Check for high complexity
609        if matches!(analysis.estimated_complexity, Complexity::High) {
610            analysis.potential_issues.push(SecurityIssue::new(
611                SecurityIssueType::HighComplexity,
612                "Code has high complexity",
613            ));
614        }
615
616        analysis
617    }
618
619    /// Estimate code complexity.
620    fn estimate_complexity(&self, info: &JavaScriptCodeInfo) -> Complexity {
621        let api_count = info.api_calls.len();
622        let loop_count = info.loop_count;
623        let depth = info.max_depth;
624        let statement_count = info.statement_count;
625
626        // Simple heuristic
627        let complexity_score = api_count * 3 + loop_count * 5 + depth * 2 + statement_count;
628
629        if complexity_score > 100 {
630            Complexity::High
631        } else if complexity_score > 50 {
632            Complexity::Medium
633        } else {
634            Complexity::Low
635        }
636    }
637
638    /// Convert code info to CodeType.
639    pub fn to_code_type(&self, info: &JavaScriptCodeInfo) -> CodeType {
640        if info.is_read_only {
641            CodeType::RestGet
642        } else {
643            CodeType::RestMutation
644        }
645    }
646}
647
648/// AST visitor for extracting info and checking safety.
649struct SafetyVisitor {
650    source_map: Lrc<SourceMap>,
651    api_calls: Vec<ApiCall>,
652    violations: Vec<SafetyViolation>,
653    variable_names: Vec<String>,
654    endpoints_accessed: HashSet<String>,
655    methods_used: HashSet<String>,
656    uses_async: bool,
657    current_depth: usize,
658    max_depth: usize,
659    loop_count: usize,
660    bounded_loops: usize,
661    statement_count: usize,
662    /// Whether the code uses spread operators in return values (potential field leakage)
663    has_spread_in_return: bool,
664    /// Whether we're currently inside a return statement
665    in_return_context: bool,
666    /// Allowed SDK operation names (camelCase). When non-empty, SDK mode validation is active.
667    sdk_operations: HashSet<String>,
668}
669
670impl SafetyVisitor {
671    fn new(source_map: &Lrc<SourceMap>) -> Self {
672        Self {
673            source_map: source_map.clone(),
674            api_calls: Vec::new(),
675            violations: Vec::new(),
676            variable_names: Vec::new(),
677            endpoints_accessed: HashSet::new(),
678            methods_used: HashSet::new(),
679            uses_async: false,
680            current_depth: 0,
681            max_depth: 0,
682            loop_count: 0,
683            bounded_loops: 0,
684            statement_count: 0,
685            has_spread_in_return: false,
686            in_return_context: false,
687            sdk_operations: HashSet::new(),
688        }
689    }
690
691    fn with_sdk_operations(mut self, operations: HashSet<String>) -> Self {
692        self.sdk_operations = operations;
693        self
694    }
695
696    fn into_info(self) -> JavaScriptCodeInfo {
697        JavaScriptCodeInfo {
698            api_calls: self.api_calls,
699            is_read_only: false, // Set later based on API calls
700            endpoints_accessed: self.endpoints_accessed,
701            methods_used: self.methods_used,
702            uses_async: self.uses_async,
703            variable_names: self.variable_names,
704            max_depth: self.max_depth,
705            loop_count: self.loop_count,
706            all_loops_bounded: self.loop_count == 0 || self.bounded_loops == self.loop_count,
707            violations: self.violations,
708            statement_count: self.statement_count,
709            output_declaration: OutputDeclaration::default(), // Set later by validator
710            has_output_spread_risk: self.has_spread_in_return,
711        }
712    }
713
714    fn span_to_location(&self, span: Span) -> CodeLocation {
715        let loc = self.source_map.lookup_char_pos(span.lo);
716        CodeLocation {
717            line: loc.line as u32,
718            column: loc.col_display as u32,
719        }
720    }
721
722    fn add_violation(&mut self, violation_type: SafetyViolationType, message: &str, span: Span) {
723        self.violations.push(SafetyViolation {
724            violation_type,
725            message: message.into(),
726            location: Some(self.span_to_location(span)),
727        });
728    }
729
730    fn check_api_call(&mut self, call: &CallExpr) {
731        // Check for api.method() pattern
732        if let Callee::Expr(expr) = &call.callee {
733            if let Expr::Member(member) = &**expr {
734                if let Expr::Ident(obj) = &*member.obj {
735                    if obj.sym.as_ref() == "api" {
736                        if let MemberProp::Ident(method_ident) = &member.prop {
737                            let method_name = method_ident.sym.as_ref();
738
739                            if !self.sdk_operations.is_empty() {
740                                // SDK mode: validate against allowed operation names
741                                if self.sdk_operations.contains(method_name) {
742                                    self.methods_used.insert(method_name.to_string());
743                                    self.endpoints_accessed
744                                        .insert(format!("sdk:{}", method_name));
745                                    // No path extraction needed for SDK calls
746                                } else {
747                                    self.add_violation(
748                                        SafetyViolationType::UnknownApiCall,
749                                        &format!(
750                                            "Unknown SDK operation: api.{}(). Check the code mode schema resource for available operations.",
751                                            method_name
752                                        ),
753                                        call.span,
754                                    );
755                                }
756                                return;
757                            }
758
759                            // HTTP mode: validate against known HTTP methods
760                            if let Some(method) = HttpMethod::from_str(method_name) {
761                                self.methods_used.insert(method_name.to_uppercase());
762
763                                // Extract path from first argument
764                                let (path, is_dynamic) = if let Some(arg) = call.args.first() {
765                                    self.extract_path(&arg.expr)
766                                } else {
767                                    ("unknown".into(), false)
768                                };
769
770                                self.endpoints_accessed.insert(path.clone());
771
772                                let loc = self.span_to_location(call.span);
773                                self.api_calls.push(ApiCall {
774                                    method,
775                                    path,
776                                    is_dynamic_path: is_dynamic,
777                                    line: loc.line,
778                                    column: loc.column,
779                                });
780                            } else {
781                                self.add_violation(
782                                    SafetyViolationType::UnknownApiCall,
783                                    &format!("Unknown api method: api.{}()", method_name),
784                                    call.span,
785                                );
786                            }
787                        }
788                    }
789                }
790            }
791        }
792    }
793
794    fn extract_path(&self, expr: &Expr) -> (String, bool) {
795        match expr {
796            Expr::Lit(Lit::Str(s)) => {
797                // Convert Wtf8Atom to String using to_string_lossy (handles WTF-8 encoding)
798                (s.value.to_string_lossy().into_owned(), false)
799            },
800            Expr::Tpl(tpl) => {
801                // Template literal - extract static parts
802                let mut path = String::new();
803                for quasi in &tpl.quasis {
804                    // quasi.raw is an Atom (UTF-8), not Wtf8Atom
805                    path.push_str(&quasi.raw.to_string());
806                    if !tpl.exprs.is_empty() {
807                        path.push_str("{...}");
808                    }
809                }
810                (path, !tpl.exprs.is_empty())
811            },
812            _ => ("dynamic".into(), true),
813        }
814    }
815
816    fn check_for_bounded(&mut self, for_of: &ForOfStmt) -> bool {
817        // Check if the iterable uses .slice()
818        if let Expr::Call(call) = &*for_of.right {
819            if let Callee::Expr(callee) = &call.callee {
820                if let Expr::Member(member) = &**callee {
821                    if let MemberProp::Ident(ident) = &member.prop {
822                        if ident.sym.as_ref() == "slice" {
823                            return true;
824                        }
825                    }
826                }
827            }
828        }
829        false
830    }
831}
832
833impl Visit for SafetyVisitor {
834    // Track depth
835    fn visit_block_stmt(&mut self, n: &BlockStmt) {
836        self.current_depth += 1;
837        self.max_depth = self.max_depth.max(self.current_depth);
838        n.visit_children_with(self);
839        self.current_depth -= 1;
840    }
841
842    // Count statements
843    fn visit_stmt(&mut self, n: &Stmt) {
844        self.statement_count += 1;
845        n.visit_children_with(self);
846    }
847
848    // Check for import/export
849    fn visit_import_decl(&mut self, n: &ImportDecl) {
850        self.add_violation(
851            SafetyViolationType::ImportExport,
852            "import statements are not allowed",
853            n.span,
854        );
855    }
856
857    fn visit_export_decl(&mut self, n: &ExportDecl) {
858        self.add_violation(
859            SafetyViolationType::ImportExport,
860            "export statements are not allowed",
861            n.span,
862        );
863    }
864
865    fn visit_export_default_decl(&mut self, n: &ExportDefaultDecl) {
866        self.add_violation(
867            SafetyViolationType::ImportExport,
868            "export default is not allowed",
869            n.span,
870        );
871    }
872
873    fn visit_export_default_expr(&mut self, n: &ExportDefaultExpr) {
874        self.add_violation(
875            SafetyViolationType::ImportExport,
876            "export default is not allowed",
877            n.span,
878        );
879    }
880
881    // Check for eval/Function
882    fn visit_call_expr(&mut self, n: &CallExpr) {
883        // Check for eval() or Function()
884        if let Callee::Expr(callee) = &n.callee {
885            if let Expr::Ident(ident) = &**callee {
886                let name = ident.sym.as_ref();
887                if name == "eval" || name == "Function" {
888                    self.add_violation(
889                        SafetyViolationType::DynamicCodeExecution,
890                        &format!("{}() is not allowed", name),
891                        n.span,
892                    );
893                }
894            }
895        }
896
897        // Check for api.method() calls
898        self.check_api_call(n);
899
900        n.visit_children_with(self);
901    }
902
903    // Check for while loops
904    fn visit_while_stmt(&mut self, n: &WhileStmt) {
905        self.add_violation(
906            SafetyViolationType::UnboundedLoop,
907            "while loops are not allowed (use bounded for...of with .slice())",
908            n.span,
909        );
910        n.visit_children_with(self);
911    }
912
913    fn visit_do_while_stmt(&mut self, n: &DoWhileStmt) {
914        self.add_violation(
915            SafetyViolationType::UnboundedLoop,
916            "do-while loops are not allowed (use bounded for...of with .slice())",
917            n.span,
918        );
919        n.visit_children_with(self);
920    }
921
922    // Check for...of loops for bounds
923    fn visit_for_of_stmt(&mut self, n: &ForOfStmt) {
924        self.loop_count += 1;
925        if self.check_for_bounded(n) {
926            self.bounded_loops += 1;
927        }
928        n.visit_children_with(self);
929    }
930
931    // Check for regular for loops (allow but track)
932    fn visit_for_stmt(&mut self, n: &ForStmt) {
933        self.loop_count += 1;
934        // Regular for loops are bounded by definition
935        self.bounded_loops += 1;
936        n.visit_children_with(self);
937    }
938
939    // Check for function declarations (only arrow functions allowed)
940    fn visit_fn_decl(&mut self, n: &FnDecl) {
941        self.add_violation(
942            SafetyViolationType::FunctionDeclaration,
943            "function declarations are not allowed (use arrow functions)",
944            n.function.span,
945        );
946        n.visit_children_with(self);
947    }
948
949    // Allow try/catch - it's just control flow and doesn't pose a security risk.
950    // This enables scripts to gracefully handle API errors.
951    fn visit_try_stmt(&mut self, n: &TryStmt) {
952        // Visit children to validate the contents of try/catch blocks
953        n.visit_children_with(self);
954    }
955
956    // Check for new keyword
957    fn visit_new_expr(&mut self, n: &NewExpr) {
958        // Allow specific constructors like Date, URL, URLSearchParams
959        let allowed = if let Expr::Ident(ident) = &*n.callee {
960            matches!(
961                ident.sym.as_ref(),
962                "Date" | "URL" | "URLSearchParams" | "Map" | "Set" | "Array"
963            )
964        } else {
965            false
966        };
967
968        if !allowed {
969            self.add_violation(
970                SafetyViolationType::NewKeyword,
971                "new keyword is only allowed for Date, URL, URLSearchParams, Map, Set, Array",
972                n.span,
973            );
974        }
975        n.visit_children_with(self);
976    }
977
978    // Check for this keyword
979    fn visit_this_expr(&mut self, n: &ThisExpr) {
980        self.add_violation(
981            SafetyViolationType::ThisKeyword,
982            "'this' keyword is not allowed",
983            n.span,
984        );
985    }
986
987    // Check for class declarations
988    fn visit_class_decl(&mut self, n: &ClassDecl) {
989        self.add_violation(
990            SafetyViolationType::ClassDeclaration,
991            "class declarations are not allowed",
992            n.class.span,
993        );
994        n.visit_children_with(self);
995    }
996
997    // Check for with statement
998    fn visit_with_stmt(&mut self, n: &WithStmt) {
999        self.add_violation(
1000            SafetyViolationType::WithStatement,
1001            "'with' statement is not allowed",
1002            n.span,
1003        );
1004        n.visit_children_with(self);
1005    }
1006
1007    // Track async
1008    fn visit_await_expr(&mut self, n: &AwaitExpr) {
1009        self.uses_async = true;
1010        n.visit_children_with(self);
1011    }
1012
1013    // Track variable declarations
1014    fn visit_var_decl(&mut self, n: &VarDecl) {
1015        for decl in &n.decls {
1016            if let Pat::Ident(ident) = &decl.name {
1017                self.variable_names.push(ident.id.sym.to_string());
1018            }
1019        }
1020        n.visit_children_with(self);
1021    }
1022
1023    // Check for generators
1024    fn visit_function(&mut self, n: &Function) {
1025        if n.is_generator {
1026            self.add_violation(
1027                SafetyViolationType::Generator,
1028                "generator functions are not allowed",
1029                n.span,
1030            );
1031        }
1032        n.visit_children_with(self);
1033    }
1034
1035    // Check for delete operator
1036    fn visit_unary_expr(&mut self, n: &UnaryExpr) {
1037        if n.op == UnaryOp::Delete {
1038            self.add_violation(
1039                SafetyViolationType::DeleteOperator,
1040                "'delete' operator is not allowed",
1041                n.span,
1042            );
1043        }
1044        n.visit_children_with(self);
1045    }
1046
1047    // Check for prototype manipulation
1048    fn visit_member_expr(&mut self, n: &MemberExpr) {
1049        if let MemberProp::Ident(ident) = &n.prop {
1050            let name = ident.sym.as_ref();
1051            if name == "__proto__" || name == "prototype" {
1052                self.add_violation(
1053                    SafetyViolationType::PrototypeManipulation,
1054                    "prototype manipulation is not allowed",
1055                    n.span,
1056                );
1057            }
1058        }
1059        n.visit_children_with(self);
1060    }
1061
1062    // Track return statements to detect spread operators in output
1063    fn visit_return_stmt(&mut self, n: &ReturnStmt) {
1064        self.in_return_context = true;
1065        n.visit_children_with(self);
1066        self.in_return_context = false;
1067    }
1068
1069    // Detect spread operators in return values (potential field leakage)
1070    fn visit_spread_element(&mut self, n: &SpreadElement) {
1071        if self.in_return_context {
1072            self.has_spread_in_return = true;
1073        }
1074        n.visit_children_with(self);
1075    }
1076}
1077
1078/// Convert a safety violation type to a security issue type.
1079fn violation_to_security_issue(violation: SafetyViolationType) -> SecurityIssueType {
1080    match violation {
1081        SafetyViolationType::DynamicCodeExecution => SecurityIssueType::PotentialInjection,
1082        SafetyViolationType::PrototypeManipulation => SecurityIssueType::PotentialInjection,
1083        SafetyViolationType::UnboundedLoop | SafetyViolationType::UnboundedForLoop => {
1084            SecurityIssueType::UnboundedQuery
1085        },
1086        _ => SecurityIssueType::HighComplexity,
1087    }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093
1094    #[test]
1095    fn test_simple_api_call() {
1096        let validator = JavaScriptValidator::default();
1097        let code = r#"
1098            const response = await api.get("/users");
1099            return response.data;
1100        "#;
1101
1102        let info = validator.validate(code).unwrap();
1103        assert!(info.is_read_only);
1104        assert_eq!(info.api_calls.len(), 1);
1105        assert_eq!(info.api_calls[0].method, HttpMethod::Get);
1106        assert!(info.endpoints_accessed.contains("/users"));
1107    }
1108
1109    #[test]
1110    fn test_multiple_api_calls() {
1111        let validator = JavaScriptValidator::default();
1112        let code = r#"
1113            const user = await api.get("/users/123");
1114            const orders = await api.get(`/users/${user.id}/orders`);
1115            return { user, orders };
1116        "#;
1117
1118        let info = validator.validate(code).unwrap();
1119        assert!(info.is_read_only);
1120        assert_eq!(info.api_calls.len(), 2);
1121        assert!(info.api_calls[1].is_dynamic_path);
1122    }
1123
1124    #[test]
1125    fn test_mutation_detection() {
1126        let validator = JavaScriptValidator::default();
1127        let code = r#"
1128            const result = await api.post("/users", { name: "test" });
1129            return result;
1130        "#;
1131
1132        let info = validator.validate(code).unwrap();
1133        assert!(!info.is_read_only);
1134        assert_eq!(info.api_calls[0].method, HttpMethod::Post);
1135    }
1136
1137    #[test]
1138    fn test_reject_eval() {
1139        let validator = JavaScriptValidator::default();
1140        let code = r#"
1141            const result = eval("api.get('/users')");
1142        "#;
1143
1144        let result = validator.validate(code);
1145        assert!(result.is_err());
1146    }
1147
1148    #[test]
1149    fn test_reject_while_loop() {
1150        let validator = JavaScriptValidator::default();
1151        let code = r#"
1152            let i = 0;
1153            while (i < 10) {
1154                await api.get("/data");
1155                i++;
1156            }
1157        "#;
1158
1159        let result = validator.validate(code);
1160        assert!(result.is_err());
1161    }
1162
1163    #[test]
1164    fn test_allow_bounded_for_of() {
1165        let validator = JavaScriptValidator::default();
1166        let code = r#"
1167            const results = [];
1168            for (const id of userIds.slice(0, 10)) {
1169                const user = await api.get(`/users/${id}`);
1170                results.push(user);
1171            }
1172            return results;
1173        "#;
1174
1175        let info = validator.validate(code).unwrap();
1176        assert!(info.all_loops_bounded);
1177        assert_eq!(info.loop_count, 1);
1178    }
1179
1180    #[test]
1181    fn test_reject_import() {
1182        let validator = JavaScriptValidator::default();
1183        let code = r#"
1184            import axios from 'axios';
1185            const result = await api.get("/users");
1186        "#;
1187
1188        let result = validator.validate(code);
1189        assert!(result.is_err());
1190    }
1191
1192    #[test]
1193    fn test_allow_arrow_functions() {
1194        let validator = JavaScriptValidator::default();
1195        let code = r#"
1196            const users = await api.get("/users");
1197            const names = users.data.map(u => u.name);
1198            return names;
1199        "#;
1200
1201        let info = validator.validate(code).unwrap();
1202        assert!(info.violations.is_empty());
1203    }
1204
1205    #[test]
1206    fn test_reject_function_declaration() {
1207        let validator = JavaScriptValidator::default();
1208        let code = r#"
1209            function fetchUser(id) {
1210                return api.get(`/users/${id}`);
1211            }
1212        "#;
1213
1214        let result = validator.validate(code);
1215        assert!(result.is_err());
1216    }
1217
1218    #[test]
1219    fn test_security_analysis_sensitive_endpoint() {
1220        let validator = JavaScriptValidator::default();
1221        let code = r#"
1222            const config = await api.get("/admin/config");
1223            return config;
1224        "#;
1225
1226        let info = validator.validate(code).unwrap();
1227        let analysis = validator.analyze_security(&info);
1228
1229        assert!(analysis
1230            .potential_issues
1231            .iter()
1232            .any(|i| matches!(i.issue_type, SecurityIssueType::SensitiveFields)));
1233    }
1234
1235    #[test]
1236    fn test_parse_returns_annotation_triple_slash() {
1237        let validator = JavaScriptValidator::default();
1238        let code = r#"
1239            /// @returns { users: Array<{ id: string, name: string }> }
1240            const users = await api.get("/users");
1241            return { users: users.map(u => ({ id: u.id, name: u.name })) };
1242        "#;
1243
1244        let info = validator.validate(code).unwrap();
1245        assert!(info.output_declaration.has_declaration);
1246        assert!(info.output_declaration.declared_fields.contains("id"));
1247        assert!(info.output_declaration.declared_fields.contains("name"));
1248        assert!(info.output_declaration.declared_fields.contains("users"));
1249    }
1250
1251    #[test]
1252    fn test_parse_returns_annotation_double_slash() {
1253        let validator = JavaScriptValidator::default();
1254        let code = r#"
1255            // @returns { products: Array<{ id: string, name: string, price: number }> }
1256            const products = await api.get("/products");
1257            return { products: products.map(p => ({ id: p.id, name: p.name, price: p.price })) };
1258        "#;
1259
1260        let info = validator.validate(code).unwrap();
1261        assert!(info.output_declaration.has_declaration);
1262        assert!(info.output_declaration.declared_fields.contains("id"));
1263        assert!(info.output_declaration.declared_fields.contains("name"));
1264        assert!(info.output_declaration.declared_fields.contains("price"));
1265        assert!(info.output_declaration.declared_fields.contains("products"));
1266    }
1267
1268    #[test]
1269    fn test_parse_returns_annotation_jsdoc() {
1270        let validator = JavaScriptValidator::default();
1271        let code = r#"
1272            /** @returns { user: { id: string, email: string } } */
1273            const user = await api.get("/users/123");
1274            return { user: { id: user.id, email: user.email } };
1275        "#;
1276
1277        let info = validator.validate(code).unwrap();
1278        assert!(info.output_declaration.has_declaration);
1279        assert!(info.output_declaration.declared_fields.contains("id"));
1280        assert!(info.output_declaration.declared_fields.contains("email"));
1281        assert!(info.output_declaration.declared_fields.contains("user"));
1282    }
1283
1284    #[test]
1285    fn test_no_returns_annotation() {
1286        let validator = JavaScriptValidator::default();
1287        let code = r#"
1288            const users = await api.get("/users");
1289            return users;
1290        "#;
1291
1292        let info = validator.validate(code).unwrap();
1293        assert!(!info.output_declaration.has_declaration);
1294        assert!(info.output_declaration.declared_fields.is_empty());
1295    }
1296
1297    #[test]
1298    fn test_spread_operator_detection() {
1299        let validator = JavaScriptValidator::default();
1300        let code = r#"
1301            const user = await api.get("/users/123");
1302            return { ...user, computed: "value" };
1303        "#;
1304
1305        let info = validator.validate(code).unwrap();
1306        assert!(info.has_output_spread_risk);
1307    }
1308
1309    #[test]
1310    fn test_no_spread_operator_in_return() {
1311        let validator = JavaScriptValidator::default();
1312        let code = r#"
1313            const user = await api.get("/users/123");
1314            return { id: user.id, name: user.name };
1315        "#;
1316
1317        let info = validator.validate(code).unwrap();
1318        assert!(!info.has_output_spread_risk);
1319    }
1320
1321    #[test]
1322    fn test_check_output_against_blocklist() {
1323        let declaration = OutputDeclaration {
1324            has_declaration: true,
1325            type_string: Some("{ id: string, ssn: string }".to_string()),
1326            declared_fields: ["id", "ssn"].iter().map(|s| s.to_string()).collect(),
1327            has_spread_risk: false,
1328        };
1329
1330        let blocked_fields: HashSet<String> =
1331            ["ssn", "password"].iter().map(|s| s.to_string()).collect();
1332
1333        let violations =
1334            JavaScriptValidator::check_output_against_blocklist(&declaration, &blocked_fields);
1335        assert_eq!(violations.len(), 1);
1336        assert!(violations[0].contains("ssn"));
1337    }
1338
1339    #[test]
1340    fn test_check_output_against_wildcard_blocklist() {
1341        let declaration = OutputDeclaration {
1342            has_declaration: true,
1343            type_string: Some("{ user: { id: string, salary: number } }".to_string()),
1344            declared_fields: ["user", "id", "salary"]
1345                .iter()
1346                .map(|s| s.to_string())
1347                .collect(),
1348            has_spread_risk: false,
1349        };
1350
1351        let blocked_fields: HashSet<String> = ["*.salary"].iter().map(|s| s.to_string()).collect();
1352
1353        let violations =
1354            JavaScriptValidator::check_output_against_blocklist(&declaration, &blocked_fields);
1355        assert_eq!(violations.len(), 1);
1356        assert!(violations[0].contains("salary"));
1357    }
1358}