cloudflare-expression-lint 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +57 -0
- package/LICENSE +21 -0
- package/README.md +375 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +363 -0
- package/dist/cli.js.map +1 -0
- package/dist/eslint-plugin.d.ts +67 -0
- package/dist/eslint-plugin.js +211 -0
- package/dist/eslint-plugin.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/lexer.d.ts +11 -0
- package/dist/lexer.js +416 -0
- package/dist/lexer.js.map +1 -0
- package/dist/parser.d.ts +16 -0
- package/dist/parser.js +320 -0
- package/dist/parser.js.map +1 -0
- package/dist/schemas/fields.d.ts +44 -0
- package/dist/schemas/fields.js +282 -0
- package/dist/schemas/fields.js.map +1 -0
- package/dist/schemas/functions.d.ts +33 -0
- package/dist/schemas/functions.js +261 -0
- package/dist/schemas/functions.js.map +1 -0
- package/dist/schemas/operators.d.ts +28 -0
- package/dist/schemas/operators.js +37 -0
- package/dist/schemas/operators.js.map +1 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.js +38 -0
- package/dist/types.js.map +1 -0
- package/dist/validator.d.ts +18 -0
- package/dist/validator.js +420 -0
- package/dist/validator.js.map +1 -0
- package/dist/yaml-scanner.d.ts +97 -0
- package/dist/yaml-scanner.js +175 -0
- package/dist/yaml-scanner.js.map +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare expression operators.
|
|
3
|
+
*
|
|
4
|
+
* Reference: https://developers.cloudflare.com/ruleset-engine/rules-language/operators/
|
|
5
|
+
*/
|
|
6
|
+
/** Comparison operators */
|
|
7
|
+
export const COMPARISON_OPERATORS = [
|
|
8
|
+
{ name: 'eq', symbols: ['=='], supportedTypes: ['String', 'Integer', 'IP', 'Float', 'Boolean'] },
|
|
9
|
+
{ name: 'ne', symbols: ['!='], supportedTypes: ['String', 'Integer', 'IP', 'Float', 'Boolean'] },
|
|
10
|
+
{ name: 'lt', symbols: ['<'], supportedTypes: ['String', 'Integer', 'Float'] },
|
|
11
|
+
{ name: 'le', symbols: ['<='], supportedTypes: ['String', 'Integer', 'Float'] },
|
|
12
|
+
{ name: 'gt', symbols: ['>'], supportedTypes: ['String', 'Integer', 'Float'] },
|
|
13
|
+
{ name: 'ge', symbols: ['>='], supportedTypes: ['String', 'Integer', 'Float'] },
|
|
14
|
+
{ name: 'contains', symbols: [], supportedTypes: ['String'] },
|
|
15
|
+
{ name: 'wildcard', symbols: [], supportedTypes: ['String'] },
|
|
16
|
+
{ name: 'strict wildcard', symbols: [], supportedTypes: ['String'] },
|
|
17
|
+
{ name: 'matches', symbols: ['~'], supportedTypes: ['String'] },
|
|
18
|
+
{ name: 'in', symbols: [], supportedTypes: ['String', 'Integer', 'IP'] },
|
|
19
|
+
];
|
|
20
|
+
/** Logical operators with precedence */
|
|
21
|
+
export const LOGICAL_OPERATORS = [
|
|
22
|
+
{ name: 'not', symbols: ['!'], supportedTypes: [], isLogical: true, precedence: 1 },
|
|
23
|
+
{ name: 'and', symbols: ['&&'], supportedTypes: [], isLogical: true, precedence: 2 },
|
|
24
|
+
{ name: 'xor', symbols: ['^^'], supportedTypes: [], isLogical: true, precedence: 3 },
|
|
25
|
+
{ name: 'or', symbols: ['||'], supportedTypes: [], isLogical: true, precedence: 4 },
|
|
26
|
+
];
|
|
27
|
+
/** All operator names and symbols for quick lookup */
|
|
28
|
+
export const ALL_COMPARISON_NAMES = new Set(COMPARISON_OPERATORS.flatMap(op => [op.name, ...op.symbols]));
|
|
29
|
+
export const ALL_LOGICAL_NAMES = new Set(LOGICAL_OPERATORS.flatMap(op => [op.name, ...op.symbols]));
|
|
30
|
+
/** Look up operator def by name or symbol */
|
|
31
|
+
export function findComparisonOperator(nameOrSymbol) {
|
|
32
|
+
return COMPARISON_OPERATORS.find(op => op.name === nameOrSymbol || op.symbols.includes(nameOrSymbol));
|
|
33
|
+
}
|
|
34
|
+
export function findLogicalOperator(nameOrSymbol) {
|
|
35
|
+
return LOGICAL_OPERATORS.find(op => op.name === nameOrSymbol || op.symbols.includes(nameOrSymbol));
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=operators.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"operators.js","sourceRoot":"","sources":["../../src/schemas/operators.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the Cloudflare expression linter.
|
|
3
|
+
*/
|
|
4
|
+
export declare enum TokenType {
|
|
5
|
+
String = "String",
|
|
6
|
+
RawString = "RawString",
|
|
7
|
+
Integer = "Integer",
|
|
8
|
+
Float = "Float",
|
|
9
|
+
Boolean = "Boolean",
|
|
10
|
+
IPAddress = "IPAddress",
|
|
11
|
+
Field = "Field",
|
|
12
|
+
Function = "Function",
|
|
13
|
+
NamedList = "NamedList",// $list_name
|
|
14
|
+
ComparisonOp = "ComparisonOp",// eq, ne, lt, le, gt, ge, ==, !=, <, <=, >, >=, contains, matches, ~, wildcard, in
|
|
15
|
+
LogicalOp = "LogicalOp",// and, or, not, xor, &&, ||, !, ^^
|
|
16
|
+
StrictWildcard = "StrictWildcard",// "strict wildcard" (two-word operator)
|
|
17
|
+
LeftParen = "LeftParen",
|
|
18
|
+
RightParen = "RightParen",
|
|
19
|
+
LeftBrace = "LeftBrace",// { for in-lists
|
|
20
|
+
RightBrace = "RightBrace",// }
|
|
21
|
+
LeftBracket = "LeftBracket",// [ for map/array access
|
|
22
|
+
RightBracket = "RightBracket",// ]
|
|
23
|
+
Comma = "Comma",
|
|
24
|
+
DotDot = "DotDot",// .. for ranges
|
|
25
|
+
Slash = "Slash",// / for CIDR notation
|
|
26
|
+
ArrayUnpack = "ArrayUnpack",// [*]
|
|
27
|
+
Placeholder = "Placeholder",// {REPLACE_...} template variables
|
|
28
|
+
EOF = "EOF"
|
|
29
|
+
}
|
|
30
|
+
export interface Token {
|
|
31
|
+
type: TokenType;
|
|
32
|
+
value: string;
|
|
33
|
+
position: number;
|
|
34
|
+
line: number;
|
|
35
|
+
column: number;
|
|
36
|
+
}
|
|
37
|
+
export type ASTNode = BooleanLiteralNode | StringLiteralNode | IntegerLiteralNode | FloatLiteralNode | IPLiteralNode | FieldAccessNode | NamedListNode | FunctionCallNode | ComparisonNode | LogicalNode | NotNode | InExpressionNode | GroupNode | ArrayUnpackNode;
|
|
38
|
+
export interface BooleanLiteralNode {
|
|
39
|
+
kind: 'BooleanLiteral';
|
|
40
|
+
value: boolean;
|
|
41
|
+
position: number;
|
|
42
|
+
}
|
|
43
|
+
export interface StringLiteralNode {
|
|
44
|
+
kind: 'StringLiteral';
|
|
45
|
+
value: string;
|
|
46
|
+
position: number;
|
|
47
|
+
}
|
|
48
|
+
export interface IntegerLiteralNode {
|
|
49
|
+
kind: 'IntegerLiteral';
|
|
50
|
+
value: number;
|
|
51
|
+
position: number;
|
|
52
|
+
}
|
|
53
|
+
export interface FloatLiteralNode {
|
|
54
|
+
kind: 'FloatLiteral';
|
|
55
|
+
value: number;
|
|
56
|
+
position: number;
|
|
57
|
+
}
|
|
58
|
+
export interface IPLiteralNode {
|
|
59
|
+
kind: 'IPLiteral';
|
|
60
|
+
value: string;
|
|
61
|
+
cidr?: number;
|
|
62
|
+
position: number;
|
|
63
|
+
}
|
|
64
|
+
export interface FieldAccessNode {
|
|
65
|
+
kind: 'FieldAccess';
|
|
66
|
+
/** Full field name (e.g., "http.request.uri.path") */
|
|
67
|
+
field: string;
|
|
68
|
+
/** Map key access, if any (e.g., "host" in headers["host"]) */
|
|
69
|
+
mapKey?: string;
|
|
70
|
+
/** Array index access, if any */
|
|
71
|
+
arrayIndex?: number;
|
|
72
|
+
position: number;
|
|
73
|
+
}
|
|
74
|
+
export interface NamedListNode {
|
|
75
|
+
kind: 'NamedList';
|
|
76
|
+
name: string;
|
|
77
|
+
position: number;
|
|
78
|
+
}
|
|
79
|
+
export interface FunctionCallNode {
|
|
80
|
+
kind: 'FunctionCall';
|
|
81
|
+
name: string;
|
|
82
|
+
args: ASTNode[];
|
|
83
|
+
position: number;
|
|
84
|
+
}
|
|
85
|
+
export interface ComparisonNode {
|
|
86
|
+
kind: 'Comparison';
|
|
87
|
+
left: ASTNode;
|
|
88
|
+
operator: string;
|
|
89
|
+
right: ASTNode;
|
|
90
|
+
position: number;
|
|
91
|
+
}
|
|
92
|
+
export interface LogicalNode {
|
|
93
|
+
kind: 'Logical';
|
|
94
|
+
left: ASTNode;
|
|
95
|
+
operator: string;
|
|
96
|
+
right: ASTNode;
|
|
97
|
+
position: number;
|
|
98
|
+
}
|
|
99
|
+
export interface NotNode {
|
|
100
|
+
kind: 'Not';
|
|
101
|
+
operand: ASTNode;
|
|
102
|
+
position: number;
|
|
103
|
+
}
|
|
104
|
+
export interface InExpressionNode {
|
|
105
|
+
kind: 'InExpression';
|
|
106
|
+
field: ASTNode;
|
|
107
|
+
values: ASTNode[];
|
|
108
|
+
negated: boolean;
|
|
109
|
+
position: number;
|
|
110
|
+
}
|
|
111
|
+
export interface GroupNode {
|
|
112
|
+
kind: 'Group';
|
|
113
|
+
expression: ASTNode;
|
|
114
|
+
position: number;
|
|
115
|
+
}
|
|
116
|
+
export interface ArrayUnpackNode {
|
|
117
|
+
kind: 'ArrayUnpack';
|
|
118
|
+
field: ASTNode;
|
|
119
|
+
position: number;
|
|
120
|
+
}
|
|
121
|
+
export type DiagnosticSeverity = 'error' | 'warning' | 'info';
|
|
122
|
+
export interface Diagnostic {
|
|
123
|
+
severity: DiagnosticSeverity;
|
|
124
|
+
message: string;
|
|
125
|
+
position?: number;
|
|
126
|
+
line?: number;
|
|
127
|
+
column?: number;
|
|
128
|
+
/** Error code for programmatic use */
|
|
129
|
+
code: string;
|
|
130
|
+
}
|
|
131
|
+
export type ExpressionType = 'filter' | 'rewrite_url' | 'rewrite_header' | 'redirect_target';
|
|
132
|
+
export interface ValidationContext {
|
|
133
|
+
/** The Cloudflare phase (e.g., "http_request_firewall_custom") */
|
|
134
|
+
phase?: string;
|
|
135
|
+
/** The type of expression being validated */
|
|
136
|
+
expressionType: ExpressionType;
|
|
137
|
+
/** Allow placeholder templates like {REPLACE_ZONE_NAME} */
|
|
138
|
+
allowPlaceholders?: boolean;
|
|
139
|
+
}
|
|
140
|
+
export interface LintResult {
|
|
141
|
+
/** The original expression string */
|
|
142
|
+
expression: string;
|
|
143
|
+
/** Whether the expression is valid (no errors) */
|
|
144
|
+
valid: boolean;
|
|
145
|
+
/** All diagnostics (errors, warnings, info) */
|
|
146
|
+
diagnostics: Diagnostic[];
|
|
147
|
+
/** Parsed AST (if parsing succeeded) */
|
|
148
|
+
ast?: ASTNode;
|
|
149
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the Cloudflare expression linter.
|
|
3
|
+
*/
|
|
4
|
+
// ── Token Types ────────────────────────────────────────────────────────
|
|
5
|
+
export var TokenType;
|
|
6
|
+
(function (TokenType) {
|
|
7
|
+
// Literals
|
|
8
|
+
TokenType["String"] = "String";
|
|
9
|
+
TokenType["RawString"] = "RawString";
|
|
10
|
+
TokenType["Integer"] = "Integer";
|
|
11
|
+
TokenType["Float"] = "Float";
|
|
12
|
+
TokenType["Boolean"] = "Boolean";
|
|
13
|
+
TokenType["IPAddress"] = "IPAddress";
|
|
14
|
+
// Identifiers
|
|
15
|
+
TokenType["Field"] = "Field";
|
|
16
|
+
TokenType["Function"] = "Function";
|
|
17
|
+
TokenType["NamedList"] = "NamedList";
|
|
18
|
+
// Operators
|
|
19
|
+
TokenType["ComparisonOp"] = "ComparisonOp";
|
|
20
|
+
TokenType["LogicalOp"] = "LogicalOp";
|
|
21
|
+
TokenType["StrictWildcard"] = "StrictWildcard";
|
|
22
|
+
// Grouping & Punctuation
|
|
23
|
+
TokenType["LeftParen"] = "LeftParen";
|
|
24
|
+
TokenType["RightParen"] = "RightParen";
|
|
25
|
+
TokenType["LeftBrace"] = "LeftBrace";
|
|
26
|
+
TokenType["RightBrace"] = "RightBrace";
|
|
27
|
+
TokenType["LeftBracket"] = "LeftBracket";
|
|
28
|
+
TokenType["RightBracket"] = "RightBracket";
|
|
29
|
+
TokenType["Comma"] = "Comma";
|
|
30
|
+
TokenType["DotDot"] = "DotDot";
|
|
31
|
+
TokenType["Slash"] = "Slash";
|
|
32
|
+
// Special
|
|
33
|
+
TokenType["ArrayUnpack"] = "ArrayUnpack";
|
|
34
|
+
TokenType["Placeholder"] = "Placeholder";
|
|
35
|
+
// End
|
|
36
|
+
TokenType["EOF"] = "EOF";
|
|
37
|
+
})(TokenType || (TokenType = {}));
|
|
38
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,0EAA0E;AAE1E,MAAM,CAAN,IAAY,SAoCX;AApCD,WAAY,SAAS;IACnB,WAAW;IACX,8BAAiB,CAAA;IACjB,oCAAuB,CAAA;IACvB,gCAAmB,CAAA;IACnB,4BAAe,CAAA;IACf,gCAAmB,CAAA;IACnB,oCAAuB,CAAA;IAEvB,cAAc;IACd,4BAAe,CAAA;IACf,kCAAqB,CAAA;IACrB,oCAAuB,CAAA;IAEvB,YAAY;IACZ,0CAA6B,CAAA;IAC7B,oCAAuB,CAAA;IACvB,8CAAiC,CAAA;IAEjC,yBAAyB;IACzB,oCAAuB,CAAA;IACvB,sCAAyB,CAAA;IACzB,oCAAuB,CAAA;IACvB,sCAAyB,CAAA;IACzB,wCAA2B,CAAA;IAC3B,0CAA6B,CAAA;IAC7B,4BAAe,CAAA;IACf,8BAAiB,CAAA;IACjB,4BAAe,CAAA;IAEf,UAAU;IACV,wCAA2B,CAAA;IAC3B,wCAA2B,CAAA;IAE3B,MAAM;IACN,wBAAW,CAAA;AACb,CAAC,EApCW,SAAS,KAAT,SAAS,QAoCpB"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validator for Cloudflare expressions.
|
|
3
|
+
*
|
|
4
|
+
* Performs semantic analysis on the AST:
|
|
5
|
+
* - Field existence and deprecation checks
|
|
6
|
+
* - Phase-specific field availability
|
|
7
|
+
* - Function existence and context validation
|
|
8
|
+
* - Function usage limits (e.g., max 1 regex_replace per expression)
|
|
9
|
+
* - Operator type checking (e.g., contains only works on String)
|
|
10
|
+
* - Header key casing warnings
|
|
11
|
+
* - Boolean comparison style hints
|
|
12
|
+
* - Expression length limits
|
|
13
|
+
*/
|
|
14
|
+
import type { ValidationContext, LintResult } from './types.js';
|
|
15
|
+
/**
|
|
16
|
+
* Validate a Cloudflare expression string.
|
|
17
|
+
*/
|
|
18
|
+
export declare function validate(expression: string, context: ValidationContext): LintResult;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validator for Cloudflare expressions.
|
|
3
|
+
*
|
|
4
|
+
* Performs semantic analysis on the AST:
|
|
5
|
+
* - Field existence and deprecation checks
|
|
6
|
+
* - Phase-specific field availability
|
|
7
|
+
* - Function existence and context validation
|
|
8
|
+
* - Function usage limits (e.g., max 1 regex_replace per expression)
|
|
9
|
+
* - Operator type checking (e.g., contains only works on String)
|
|
10
|
+
* - Header key casing warnings
|
|
11
|
+
* - Boolean comparison style hints
|
|
12
|
+
* - Expression length limits
|
|
13
|
+
*/
|
|
14
|
+
import { parse } from './parser.js';
|
|
15
|
+
import { findField, findBaseField } from './schemas/fields.js';
|
|
16
|
+
import { findFunction } from './schemas/functions.js';
|
|
17
|
+
import { findComparisonOperator } from './schemas/operators.js';
|
|
18
|
+
const MAX_EXPRESSION_LENGTH = 4096;
|
|
19
|
+
/**
|
|
20
|
+
* Validate a Cloudflare expression string.
|
|
21
|
+
*/
|
|
22
|
+
export function validate(expression, context) {
|
|
23
|
+
const diagnostics = [];
|
|
24
|
+
// Check expression length
|
|
25
|
+
if (expression.length > MAX_EXPRESSION_LENGTH) {
|
|
26
|
+
diagnostics.push({
|
|
27
|
+
severity: 'warning',
|
|
28
|
+
message: `Expression is ${expression.length} characters, exceeding the ${MAX_EXPRESSION_LENGTH} character limit`,
|
|
29
|
+
code: 'expression-too-long',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
// Try to parse
|
|
33
|
+
let ast;
|
|
34
|
+
try {
|
|
35
|
+
ast = parse(expression);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
severity: 'error',
|
|
40
|
+
message: err instanceof Error ? err.message : String(err),
|
|
41
|
+
code: 'parse-error',
|
|
42
|
+
});
|
|
43
|
+
return { expression, valid: false, diagnostics };
|
|
44
|
+
}
|
|
45
|
+
// Walk the AST and collect diagnostics
|
|
46
|
+
const walker = new ASTWalker(context, diagnostics);
|
|
47
|
+
walker.walk(ast);
|
|
48
|
+
// Check function usage limits and regex count
|
|
49
|
+
walker.checkFunctionLimits();
|
|
50
|
+
walker.checkRegexCount();
|
|
51
|
+
const hasErrors = diagnostics.some(d => d.severity === 'error');
|
|
52
|
+
return { expression, valid: !hasErrors, diagnostics, ast };
|
|
53
|
+
}
|
|
54
|
+
class ASTWalker {
|
|
55
|
+
context;
|
|
56
|
+
diagnostics;
|
|
57
|
+
functionCounts = new Map();
|
|
58
|
+
regexCount = 0;
|
|
59
|
+
constructor(context, diagnostics) {
|
|
60
|
+
this.context = context;
|
|
61
|
+
this.diagnostics = diagnostics;
|
|
62
|
+
}
|
|
63
|
+
walk(node) {
|
|
64
|
+
switch (node.kind) {
|
|
65
|
+
case 'FieldAccess':
|
|
66
|
+
this.validateField(node.field, node.position);
|
|
67
|
+
this.validateHeaderKeyCasing(node);
|
|
68
|
+
break;
|
|
69
|
+
case 'FunctionCall':
|
|
70
|
+
this.validateFunction(node.name, node.position);
|
|
71
|
+
this.functionCounts.set(node.name, (this.functionCounts.get(node.name) ?? 0) + 1);
|
|
72
|
+
for (const arg of node.args) {
|
|
73
|
+
this.walk(arg);
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case 'Comparison':
|
|
77
|
+
this.walk(node.left);
|
|
78
|
+
this.walk(node.right);
|
|
79
|
+
this.validateOperatorTypes(node);
|
|
80
|
+
this.validateBooleanStyle(node);
|
|
81
|
+
this.validateWildcardPattern(node);
|
|
82
|
+
this.countRegexUsage(node);
|
|
83
|
+
break;
|
|
84
|
+
case 'Logical':
|
|
85
|
+
this.walk(node.left);
|
|
86
|
+
this.walk(node.right);
|
|
87
|
+
break;
|
|
88
|
+
case 'Not':
|
|
89
|
+
this.walk(node.operand);
|
|
90
|
+
break;
|
|
91
|
+
case 'InExpression':
|
|
92
|
+
this.walk(node.field);
|
|
93
|
+
for (const val of node.values) {
|
|
94
|
+
this.walk(val);
|
|
95
|
+
}
|
|
96
|
+
this.validateInExpressionTypes(node);
|
|
97
|
+
this.validateEmptyInList(node);
|
|
98
|
+
break;
|
|
99
|
+
case 'Group':
|
|
100
|
+
this.walk(node.expression);
|
|
101
|
+
break;
|
|
102
|
+
case 'ArrayUnpack':
|
|
103
|
+
this.walk(node.field);
|
|
104
|
+
break;
|
|
105
|
+
case 'NamedList':
|
|
106
|
+
this.validateNamedList(node.name, node.position);
|
|
107
|
+
break;
|
|
108
|
+
case 'BooleanLiteral':
|
|
109
|
+
case 'StringLiteral':
|
|
110
|
+
case 'IntegerLiteral':
|
|
111
|
+
case 'FloatLiteral':
|
|
112
|
+
break;
|
|
113
|
+
case 'IPLiteral':
|
|
114
|
+
if (node.cidr !== undefined) {
|
|
115
|
+
this.validateCIDRMask(node.value, node.cidr, node.position);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ── Field Validation ───────────────────────────────────────────────
|
|
121
|
+
validateField(fieldName, position) {
|
|
122
|
+
const field = findField(fieldName);
|
|
123
|
+
if (field) {
|
|
124
|
+
if (field.deprecated) {
|
|
125
|
+
this.diagnostics.push({
|
|
126
|
+
severity: 'warning',
|
|
127
|
+
message: `Field "${fieldName}" is deprecated${field.replacement ? `. Use "${field.replacement}" instead` : ''}`,
|
|
128
|
+
code: 'deprecated-field',
|
|
129
|
+
position,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (this.context.phase && field.phases && field.phases.length > 0) {
|
|
133
|
+
if (!field.phases.includes(this.context.phase)) {
|
|
134
|
+
this.diagnostics.push({
|
|
135
|
+
severity: 'error',
|
|
136
|
+
message: `Field "${fieldName}" is not available in phase "${this.context.phase}". Available in: ${field.phases.join(', ')}`,
|
|
137
|
+
code: 'field-not-in-phase',
|
|
138
|
+
position,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const baseField = findBaseField(fieldName);
|
|
145
|
+
if (baseField) {
|
|
146
|
+
if (baseField.deprecated) {
|
|
147
|
+
this.diagnostics.push({
|
|
148
|
+
severity: 'warning',
|
|
149
|
+
message: `Field "${baseField.name}" is deprecated${baseField.replacement ? `. Use "${baseField.replacement}" instead` : ''}`,
|
|
150
|
+
code: 'deprecated-field',
|
|
151
|
+
position,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.diagnostics.push({
|
|
157
|
+
severity: 'error',
|
|
158
|
+
message: `Unknown field "${fieldName}"`,
|
|
159
|
+
code: 'unknown-field',
|
|
160
|
+
position,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// ── Header Key Casing ──────────────────────────────────────────────
|
|
164
|
+
validateHeaderKeyCasing(node) {
|
|
165
|
+
if (node.kind !== 'FieldAccess')
|
|
166
|
+
return;
|
|
167
|
+
if (!node.mapKey)
|
|
168
|
+
return;
|
|
169
|
+
// Only check http.request.headers and http.response.headers
|
|
170
|
+
const isHeaderField = node.field === 'http.request.headers' ||
|
|
171
|
+
node.field === 'http.response.headers' ||
|
|
172
|
+
node.field === 'raw.http.request.headers' ||
|
|
173
|
+
node.field === 'raw.http.response.headers';
|
|
174
|
+
if (isHeaderField && node.mapKey !== node.mapKey.toLowerCase()) {
|
|
175
|
+
this.diagnostics.push({
|
|
176
|
+
severity: 'warning',
|
|
177
|
+
message: `Header key "${node.mapKey}" should be lowercase. Cloudflare normalizes header names to lowercase, so "${node.mapKey}" will never match. Use "${node.mapKey.toLowerCase()}" instead.`,
|
|
178
|
+
code: 'header-key-not-lowercase',
|
|
179
|
+
position: node.position,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── Operator Type Checking ─────────────────────────────────────────
|
|
184
|
+
validateOperatorTypes(node) {
|
|
185
|
+
if (node.kind !== 'Comparison')
|
|
186
|
+
return;
|
|
187
|
+
const operator = node.operator;
|
|
188
|
+
const opDef = findComparisonOperator(operator);
|
|
189
|
+
if (!opDef)
|
|
190
|
+
return; // Unknown operator — skip type check
|
|
191
|
+
// Resolve the field type from the left-hand side
|
|
192
|
+
const fieldType = this.resolveFieldType(node.left);
|
|
193
|
+
if (!fieldType)
|
|
194
|
+
return; // Can't determine type (e.g., function call) — skip
|
|
195
|
+
// Check if the operator supports this field type
|
|
196
|
+
if (!opDef.supportedTypes.includes(fieldType)) {
|
|
197
|
+
this.diagnostics.push({
|
|
198
|
+
severity: 'error',
|
|
199
|
+
message: `Operator "${opDef.name}" does not support ${fieldType} fields. Supported types: ${opDef.supportedTypes.join(', ')}`,
|
|
200
|
+
code: 'operator-type-mismatch',
|
|
201
|
+
position: node.position,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// ── Boolean Style Hints ────────────────────────────────────────────
|
|
206
|
+
validateBooleanStyle(node) {
|
|
207
|
+
if (node.kind !== 'Comparison')
|
|
208
|
+
return;
|
|
209
|
+
// Check for pattern: boolean_field == true or boolean_field eq true
|
|
210
|
+
const op = node.operator;
|
|
211
|
+
if (op !== '==' && op !== 'eq')
|
|
212
|
+
return;
|
|
213
|
+
// RHS must be boolean literal `true`
|
|
214
|
+
if (node.right.kind !== 'BooleanLiteral' || node.right.value !== true)
|
|
215
|
+
return;
|
|
216
|
+
// LHS must be a field with Boolean type
|
|
217
|
+
const fieldType = this.resolveFieldType(node.left);
|
|
218
|
+
if (fieldType !== 'Boolean')
|
|
219
|
+
return;
|
|
220
|
+
const fieldName = node.left.kind === 'FieldAccess' ? node.left.field : 'field';
|
|
221
|
+
this.diagnostics.push({
|
|
222
|
+
severity: 'info',
|
|
223
|
+
message: `Prefer bare "${fieldName}" over "${fieldName} ${op} true"`,
|
|
224
|
+
code: 'prefer-bare-boolean',
|
|
225
|
+
position: node.position,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// ── Resolve Field Type ─────────────────────────────────────────────
|
|
229
|
+
/**
|
|
230
|
+
* Attempt to determine the FieldType of an AST node.
|
|
231
|
+
* Returns undefined if the type cannot be determined.
|
|
232
|
+
*/
|
|
233
|
+
resolveFieldType(node) {
|
|
234
|
+
switch (node.kind) {
|
|
235
|
+
case 'FieldAccess': {
|
|
236
|
+
const field = findField(node.field);
|
|
237
|
+
if (field) {
|
|
238
|
+
// If this field has map key or array index access, resolve to element type
|
|
239
|
+
if (node.mapKey !== undefined || node.arrayIndex !== undefined) {
|
|
240
|
+
if (field.type === 'Map')
|
|
241
|
+
return 'String';
|
|
242
|
+
if (field.type === 'Array')
|
|
243
|
+
return 'String';
|
|
244
|
+
}
|
|
245
|
+
return field.type;
|
|
246
|
+
}
|
|
247
|
+
const base = findBaseField(node.field);
|
|
248
|
+
if (base) {
|
|
249
|
+
// Map/Array access yields String (the value type)
|
|
250
|
+
if (base.type === 'Map')
|
|
251
|
+
return 'String';
|
|
252
|
+
if (base.type === 'Array')
|
|
253
|
+
return 'String';
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
case 'FunctionCall': {
|
|
258
|
+
const func = findFunction(node.name);
|
|
259
|
+
return func?.returnType;
|
|
260
|
+
}
|
|
261
|
+
case 'StringLiteral':
|
|
262
|
+
return 'String';
|
|
263
|
+
case 'IntegerLiteral':
|
|
264
|
+
return 'Integer';
|
|
265
|
+
case 'FloatLiteral':
|
|
266
|
+
return 'Float';
|
|
267
|
+
case 'BooleanLiteral':
|
|
268
|
+
return 'Boolean';
|
|
269
|
+
case 'IPLiteral':
|
|
270
|
+
return 'IP';
|
|
271
|
+
case 'ArrayUnpack':
|
|
272
|
+
// Array unpack produces individual elements — typically String
|
|
273
|
+
return 'String';
|
|
274
|
+
case 'Group':
|
|
275
|
+
return this.resolveFieldType(node.expression);
|
|
276
|
+
default:
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ── Wildcard Pattern Validation ─────────────────────────────────────
|
|
281
|
+
validateWildcardPattern(node) {
|
|
282
|
+
if (node.kind !== 'Comparison')
|
|
283
|
+
return;
|
|
284
|
+
if (node.operator !== 'wildcard' && node.operator !== 'strict wildcard')
|
|
285
|
+
return;
|
|
286
|
+
// Check the RHS for double asterisks
|
|
287
|
+
if (node.right.kind === 'StringLiteral' && node.right.value.includes('**')) {
|
|
288
|
+
this.diagnostics.push({
|
|
289
|
+
severity: 'warning',
|
|
290
|
+
message: `Wildcard pattern contains "**" which is not allowed. Use a single "*" instead.`,
|
|
291
|
+
code: 'invalid-wildcard-pattern',
|
|
292
|
+
position: node.right.position,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ── Regex Count ────────────────────────────────────────────────────
|
|
297
|
+
countRegexUsage(node) {
|
|
298
|
+
if (node.kind !== 'Comparison')
|
|
299
|
+
return;
|
|
300
|
+
if (node.operator === 'matches' || node.operator === '~') {
|
|
301
|
+
this.regexCount++;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
checkRegexCount() {
|
|
305
|
+
if (this.regexCount > 64) {
|
|
306
|
+
this.diagnostics.push({
|
|
307
|
+
severity: 'warning',
|
|
308
|
+
message: `Expression uses ${this.regexCount} regex patterns, exceeding the limit of 64 per rule`,
|
|
309
|
+
code: 'too-many-regex',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// ── In-Expression Type Checking ────────────────────────────────────
|
|
314
|
+
validateInExpressionTypes(node) {
|
|
315
|
+
if (node.kind !== 'InExpression')
|
|
316
|
+
return;
|
|
317
|
+
const fieldType = this.resolveFieldType(node.field);
|
|
318
|
+
if (!fieldType)
|
|
319
|
+
return;
|
|
320
|
+
// `in` supports String, Integer, and IP — not Boolean or Float
|
|
321
|
+
const supportedInTypes = ['String', 'Integer', 'IP'];
|
|
322
|
+
if (!supportedInTypes.includes(fieldType)) {
|
|
323
|
+
this.diagnostics.push({
|
|
324
|
+
severity: 'error',
|
|
325
|
+
message: `Operator "in" does not support ${fieldType} fields. Supported types: ${supportedInTypes.join(', ')}`,
|
|
326
|
+
code: 'operator-type-mismatch',
|
|
327
|
+
position: node.position,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// ── Empty In-List ──────────────────────────────────────────────────
|
|
332
|
+
validateEmptyInList(node) {
|
|
333
|
+
if (node.kind !== 'InExpression')
|
|
334
|
+
return;
|
|
335
|
+
if (node.values.length === 0) {
|
|
336
|
+
this.diagnostics.push({
|
|
337
|
+
severity: 'warning',
|
|
338
|
+
message: 'Empty in-list "{}" — this expression will never match',
|
|
339
|
+
code: 'empty-in-list',
|
|
340
|
+
position: node.position,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// ── Named List Validation ───────────────────────────────────────────
|
|
345
|
+
validateNamedList(name, position) {
|
|
346
|
+
// Strip the leading $
|
|
347
|
+
const listName = name.startsWith('$') ? name.slice(1) : name;
|
|
348
|
+
// Managed lists use cf.* prefix — these are always valid
|
|
349
|
+
if (listName.startsWith('cf.'))
|
|
350
|
+
return;
|
|
351
|
+
// Custom list names must be lowercase, numbers, and underscores only
|
|
352
|
+
if (!/^[a-z0-9_]+$/.test(listName)) {
|
|
353
|
+
this.diagnostics.push({
|
|
354
|
+
severity: 'warning',
|
|
355
|
+
message: `Named list "${name}" may be invalid. Custom list names must use only lowercase letters, numbers, and underscores (a-z, 0-9, _)`,
|
|
356
|
+
code: 'invalid-list-name',
|
|
357
|
+
position,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// ── CIDR Mask Validation ───────────────────────────────────────────
|
|
362
|
+
validateCIDRMask(ip, mask, position) {
|
|
363
|
+
// Determine if IPv4 or IPv6
|
|
364
|
+
const isIPv6 = ip.includes(':');
|
|
365
|
+
const maxMask = isIPv6 ? 128 : 32;
|
|
366
|
+
if (mask < 0 || mask > maxMask) {
|
|
367
|
+
this.diagnostics.push({
|
|
368
|
+
severity: 'error',
|
|
369
|
+
message: `Invalid CIDR mask /${mask} for ${isIPv6 ? 'IPv6' : 'IPv4'} address. Must be 0-${maxMask}`,
|
|
370
|
+
code: 'invalid-cidr-mask',
|
|
371
|
+
position,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// ── Function Validation ────────────────────────────────────────────
|
|
376
|
+
validateFunction(funcName, position) {
|
|
377
|
+
const func = findFunction(funcName);
|
|
378
|
+
if (!func) {
|
|
379
|
+
this.diagnostics.push({
|
|
380
|
+
severity: 'error',
|
|
381
|
+
message: `Unknown function "${funcName}"`,
|
|
382
|
+
code: 'unknown-function',
|
|
383
|
+
position,
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (!func.contexts.includes('all')) {
|
|
388
|
+
const exprContext = this.mapExpressionTypeToContext(this.context.expressionType);
|
|
389
|
+
if (!func.contexts.includes(exprContext)) {
|
|
390
|
+
this.diagnostics.push({
|
|
391
|
+
severity: 'error',
|
|
392
|
+
message: `Function "${funcName}" is not available in ${this.context.expressionType} expressions. Available in: ${func.contexts.join(', ')}`,
|
|
393
|
+
code: 'function-not-in-context',
|
|
394
|
+
position,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
checkFunctionLimits() {
|
|
400
|
+
for (const [funcName, count] of this.functionCounts) {
|
|
401
|
+
const func = findFunction(funcName);
|
|
402
|
+
if (func?.maxPerExpression && count > func.maxPerExpression) {
|
|
403
|
+
this.diagnostics.push({
|
|
404
|
+
severity: 'error',
|
|
405
|
+
message: `Function "${funcName}" can only be used ${func.maxPerExpression} time(s) per expression, but was used ${count} times`,
|
|
406
|
+
code: 'function-max-exceeded',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
mapExpressionTypeToContext(exprType) {
|
|
412
|
+
switch (exprType) {
|
|
413
|
+
case 'filter': return 'filter';
|
|
414
|
+
case 'rewrite_url': return 'rewrite_url';
|
|
415
|
+
case 'rewrite_header': return 'rewrite_header';
|
|
416
|
+
case 'redirect_target': return 'redirect_target';
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
//# sourceMappingURL=validator.js.map
|