diff --git a/tools/hrw4u/src/ast_nodes.py b/tools/hrw4u/src/ast_nodes.py index acf5bacccb3..2b8741e8ea8 100644 --- a/tools/hrw4u/src/ast_nodes.py +++ b/tools/hrw4u/src/ast_nodes.py @@ -25,16 +25,22 @@ "IdentValue", "IPValue", "ParamRef", + "BoolValue", + "NumberValue", "RegexValue", + "SetValue", + "IpRangeValue", "ValueExpr", "Node", "Target", "Assignment", "FunctionCall", "Break", + "Comment", "Comparison", "LogicalOp", "NotOp", + "Group", "BoolLiteral", "IdentCondition", "ElifBranch", @@ -72,12 +78,32 @@ class ParamRef: raw: str +@dataclass(frozen=True, kw_only=True) +class BoolValue: + raw: str # source spelling, e.g. "TRUE", "true", "TRue" + + +@dataclass(frozen=True, kw_only=True) +class NumberValue: + raw: str # source spelling: header_rewrite echoes it, so 007 is three bytes and not 7 + + @dataclass(frozen=True, kw_only=True) class RegexValue: raw: str -ValueExpr = Union[LiteralStringValue, IdentValue, IPValue, ParamRef, int, bool, tuple[IPValue, ...]] +@dataclass(frozen=True, kw_only=True) +class SetValue: + raw: str # bracket-stripped source text, quoting preserved + + +@dataclass(frozen=True, kw_only=True) +class IpRangeValue: + raw: str # verbatim source text + + +ValueExpr = Union[LiteralStringValue, IdentValue, IPValue, ParamRef, BoolValue, NumberValue, IpRangeValue] @dataclass(frozen=True, kw_only=True) @@ -119,11 +145,16 @@ class Break(Node): pass +@dataclass(frozen=True, kw_only=True) +class Comment(Node): + text: str + + @dataclass(frozen=True, kw_only=True) class Comparison(Node): left: IdentValue | FunctionCall operator: str # "==", "!=", ">", "<", "~", "!~", "in", "!in" - right: ValueExpr | RegexValue | tuple[ValueExpr, ...] + right: ValueExpr | RegexValue | SetValue | IpRangeValue modifiers: tuple[str, ...] @@ -139,6 +170,11 @@ class NotOp(Node): operand: ConditionExpr +@dataclass(frozen=True, kw_only=True) +class Group(Node): + inner: ConditionExpr + + @dataclass(frozen=True, kw_only=True) class BoolLiteral(Node): value: bool @@ -161,6 +197,7 @@ class IfBlock(Node): body: tuple[BodyNode, ...] elif_branches: tuple[ElifBranch, ...] else_body: tuple[BodyNode, ...] + has_else: bool # an empty else body is not the same as no else clause @dataclass(frozen=True, kw_only=True) @@ -185,7 +222,7 @@ class VarDecl(Node): @dataclass(frozen=True, kw_only=True) class VarSection(Node): scope: str - declarations: tuple[VarDecl, ...] + declarations: tuple[VarDecl | Comment, ...] @dataclass(frozen=True, kw_only=True) @@ -206,6 +243,6 @@ class HRW4UAST: # Type aliases: must follow all class definitions (evaluated at runtime). -ConditionExpr = Union[Comparison, LogicalOp, NotOp, BoolLiteral, IdentCondition, FunctionCall] -BodyNode = Union[Assignment, FunctionCall, IfBlock, Break] -TopLevelNode = Union[UseDirective, VarSection, ProcedureDecl, Section] +ConditionExpr = Union[Comparison, LogicalOp, NotOp, Group, BoolLiteral, IdentCondition, FunctionCall] +BodyNode = Union[Assignment, FunctionCall, IfBlock, Break, Comment] +TopLevelNode = Union[UseDirective, VarSection, ProcedureDecl, Section, Comment] diff --git a/tools/hrw4u/src/ast_visitor.py b/tools/hrw4u/src/ast_visitor.py index 4a66ec0a710..df376cedb78 100644 --- a/tools/hrw4u/src/ast_visitor.py +++ b/tools/hrw4u/src/ast_visitor.py @@ -29,6 +29,9 @@ class ASTVisitor(hrw4uVisitor): # method has an explicit return type and full control over how # child results are assembled into parent AST nodes. + def _visit_comment(self, ctx) -> Comment: + return Comment(text=ctx.COMMENT().getText(), line=ctx.start.line) + def visitProgram(self, ctx) -> HRW4UAST: items = [] for item in ctx.programItem(): @@ -39,7 +42,7 @@ def visitProgram(self, ctx) -> HRW4UAST: elif item.section() is not None: items.append(self._visit_section(item.section())) elif item.commentLine() is not None: - pass + items.append(self._visit_comment(item.commentLine())) else: raise ValueError(f"Unhandled programItem alternative at line {item.start.line}") return HRW4UAST(body=tuple(items)) @@ -75,7 +78,7 @@ def _visit_var_section(self, ctx, scope) -> VarSection: if var_item.variableDecl() is not None: decls.append(self._visit_var_decl(var_item.variableDecl())) elif var_item.commentLine() is not None: - pass + decls.append(self._visit_comment(var_item.commentLine())) else: raise ValueError(f"Unhandled variablesItem alternative at line {var_item.start.line}") return VarSection(scope=scope, declarations=tuple(decls), line=ctx.start.line) @@ -93,7 +96,7 @@ def _visit_body(self, items) -> list[BodyNode]: elif item.conditional() is not None: result.append(self._visit_conditional(item.conditional())) elif item.commentLine() is not None: - pass + result.append(self._visit_comment(item.commentLine())) else: raise ValueError(f"Unhandled body item alternative at line {item.start.line}") return result @@ -125,19 +128,19 @@ def _visit_function_call(self, ctx) -> FunctionCall: def _extract_value(self, ctx) -> ValueExpr: if ctx.number is not None: - return int(ctx.number.text) + return NumberValue(raw=ctx.number.text) if ctx.str_ is not None: return LiteralStringValue(raw=ctx.str_.text[1:-1]) if ctx.TRUE(): - return True + return BoolValue(raw=ctx.TRUE().getText()) if ctx.FALSE(): - return False + return BoolValue(raw=ctx.FALSE().getText()) if ctx.ident is not None: return IdentValue(raw=ctx.ident.text) if ctx.ip(): return IPValue(raw=ctx.ip().getText()) if ctx.iprange(): - return tuple(IPValue(raw=ip.getText()) for ip in ctx.iprange().ip()) + return IpRangeValue(raw=ctx.iprange().getText()) if ctx.paramRef(): return ParamRef(raw=ctx.paramRef().IDENT().getText()) raise ValueError(f"Unhandled value alternative at line {ctx.start.line}") @@ -155,13 +158,17 @@ def _visit_conditional(self, ctx) -> IfBlock: elif_body = tuple(self._visit_body(elif_block.blockItem())) if elif_block else () elif_branches.append(ElifBranch(condition=elif_cond, body=elif_body, line=elif_ctx.start.line)) - else_body = () - if ctx.elseClause(): - else_block = ctx.elseClause().block() - if else_block: - else_body = tuple(self._visit_body(else_block.blockItem())) + else_clause = ctx.elseClause() + else_block = else_clause.block() if else_clause else None + else_body = tuple(self._visit_body(else_block.blockItem())) if else_block else () - return IfBlock(condition=condition, body=body, elif_branches=tuple(elif_branches), else_body=else_body, line=ctx.start.line) + return IfBlock( + condition=condition, + body=body, + elif_branches=tuple(elif_branches), + else_body=else_body, + has_else=else_clause is not None, + line=ctx.start.line) def _visit_condition(self, ctx) -> ConditionExpr: return self._visit_expression(ctx.expression()) @@ -184,7 +191,7 @@ def _visit_factor(self, ctx) -> ConditionExpr: if ctx.getChildCount() == 2 and ctx.getChild(0).getText() == "!": return NotOp(operand=self._visit_factor(ctx.factor()), line=ctx.start.line) if ctx.LPAREN(): - return self._visit_expression(ctx.expression()) + return Group(inner=self._visit_expression(ctx.expression()), line=ctx.start.line) if ctx.functionCall(): return self._visit_function_call(ctx.functionCall()) if ctx.comparison(): @@ -231,14 +238,14 @@ def _detect_comparison_operator(self, ctx) -> str: return "in" raise ValueError(f"Unhandled comparison operator at line {ctx.start.line}") - def _extract_comparison_rhs(self, ctx, operator) -> ValueExpr | RegexValue | tuple[ValueExpr, ...]: + def _extract_comparison_rhs(self, ctx, operator) -> ValueExpr | RegexValue | SetValue | IpRangeValue: if operator in ("~", "!~"): return RegexValue(raw=ctx.regex().getText()[1:-1]) if operator in ("in", "!in"): if ctx.set_(): - return tuple(self._extract_value(v) for v in ctx.set_().value()) + return SetValue(raw=ctx.set_().getText()[1:-1]) if ctx.iprange(): - return tuple(IPValue(raw=ip.getText()) for ip in ctx.iprange().ip()) + return IpRangeValue(raw=ctx.iprange().getText()) if ctx.value(): return self._extract_value(ctx.value()) raise ValueError(f"Unhandled comparison RHS at line {ctx.start.line}") diff --git a/tools/hrw4u/tests/ast_unparse.py b/tools/hrw4u/tests/ast_unparse.py new file mode 100644 index 00000000000..11cd17fe6ae --- /dev/null +++ b/tools/hrw4u/tests/ast_unparse.py @@ -0,0 +1,144 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Render an AST back to hrw4u source. Test-only; see test_ast_roundtrip.py for why. + +Whitespace is not reproduced: the emitter derives its own indentation and never reads the +source's. +""" + +from __future__ import annotations + +from hrw4u.ast_nodes import * + +INDENT = " " + + +def unparse(ast: HRW4UAST) -> str: + return "\n".join(_top_level(item) for item in ast.body) + "\n" + + +def _top_level(node: TopLevelNode) -> str: + match node: + case Comment(): + return node.text + case UseDirective(): + return f"use {node.spec}" + case ProcedureDecl(): + params = ", ".join(_proc_param(p) for p in node.params) + return _braced(f"procedure {node.name}({params})", [_body_item(b, 1) for b in node.body]) + case VarSection(): + keyword = "SESSION_VARS" if node.scope == "session" else "VARS" + return _braced(keyword, [_var_item(d) for d in node.declarations]) + case Section(): + return _braced(node.type, [_body_item(b, 1) for b in node.body]) + raise ValueError(f"unparse: unhandled top-level node {type(node).__name__}") + + +def _braced(header: str, lines: list[str]) -> str: + return "\n".join([f"{header} {{", *(f"{INDENT}{line}" for line in lines), "}"]) + + +def _proc_param(p: ProcParam) -> str: + return f"${p.name}" if p.default is None else f"${p.name}={_value(p.default)}" + + +def _var_item(node: VarDecl | Comment) -> str: + if isinstance(node, Comment): + return node.text + slot = "" if node.slot is None else f" @{node.slot}" + return f"{node.name}: {node.type_name}{slot};" + + +def _body_item(node: BodyNode, depth: int) -> str: + match node: + case Comment(): + return node.text + case Break(): + return "break;" + case FunctionCall(): + return f"{_call(node)};" + case Assignment(): + return f"{_target(node.target)} {node.operator} {_value(node.value)};" + case IfBlock(): + return _if_block(node, depth) + raise ValueError(f"unparse: unhandled body node {type(node).__name__}") + + +def _if_block(node: IfBlock, depth: int) -> str: + pad = INDENT * depth + lines = [f"if {_condition(node.condition)} {{"] + lines += [f"{INDENT}{line}" for line in _nested(node.body, depth)] + for arm in node.elif_branches: + lines.append(f"}} elif {_condition(arm.condition)} {{") + lines += [f"{INDENT}{line}" for line in _nested(arm.body, depth)] + if node.has_else: + lines.append("} else {") + lines += [f"{INDENT}{line}" for line in _nested(node.else_body, depth)] + lines.append("}") + return f"\n{pad}".join(lines) + + +def _nested(body: tuple[BodyNode, ...], depth: int) -> list[str]: + return [line for item in body for line in _body_item(item, depth + 1).splitlines()] + + +def _target(t: Target) -> str: + return t.field if t.namespace is None else f"{t.namespace}.{t.field}" + + +def _call(node: FunctionCall) -> str: + return f"{node.name}({', '.join(_value(a) for a in node.args)})" + + +def _condition(node: ConditionExpr) -> str: + match node: + case Group(): + return f"({_condition(node.inner)})" + case LogicalOp(): + return f"{_condition(node.left)} {node.operator} {_condition(node.right)}" + case NotOp(): + return f"!{_condition(node.operand)}" + case BoolLiteral(): + return "true" if node.value else "false" + case IdentCondition(): + return node.name + case FunctionCall(): + return _call(node) + case Comparison(): + return _comparison(node) + raise ValueError(f"unparse: unhandled condition node {type(node).__name__}") + + +def _comparison(node: Comparison) -> str: + left = node.left.raw if isinstance(node.left, IdentValue) else _call(node.left) + mods = f" with {', '.join(node.modifiers)}" if node.modifiers else "" + return f"{left} {node.operator} {_value(node.right)}{mods}" + + +def _value(v: ValueExpr | RegexValue | SetValue) -> str: + match v: + case LiteralStringValue(): + return f'"{v.raw}"' + case NumberValue() | BoolValue() | IdentValue() | IPValue() | IpRangeValue(): + return v.raw + case ParamRef(): + return f"${v.raw}" + case RegexValue(): + return f"/{v.raw}/" + case SetValue(): + return f"[{v.raw}]" + raise ValueError(f"unparse: unhandled value {type(v).__name__}") diff --git a/tools/hrw4u/tests/data/ops/bool-spelling.input.txt b/tools/hrw4u/tests/data/ops/bool-spelling.input.txt new file mode 100644 index 00000000000..750713155e8 --- /dev/null +++ b/tools/hrw4u/tests/data/ops/bool-spelling.input.txt @@ -0,0 +1,7 @@ +# The emitter echoes a bool's spelling, so the AST cannot normalize one anywhere: +# an assignment RHS is not the only value context that reaches header_rewrite. +REMAP { + if inbound.req.X-Debug == TRUE { + set-config("proxy.config.http.cache.http", FALSE); + } +} diff --git a/tools/hrw4u/tests/data/ops/bool-spelling.output.txt b/tools/hrw4u/tests/data/ops/bool-spelling.output.txt new file mode 100644 index 00000000000..25575881097 --- /dev/null +++ b/tools/hrw4u/tests/data/ops/bool-spelling.output.txt @@ -0,0 +1,5 @@ +# The emitter echoes a bool's spelling, so the AST cannot normalize one anywhere: +# an assignment RHS is not the only value context that reaches header_rewrite. +cond %{REMAP_PSEUDO_HOOK} [AND] +cond %{CLIENT-HEADER:X-Debug} =TRUE + set-config "proxy.config.http.cache.http" FALSE diff --git a/tools/hrw4u/tests/data/ops/exceptions.txt b/tools/hrw4u/tests/data/ops/exceptions.txt index a991b1217a7..49bb3b414fb 100644 --- a/tools/hrw4u/tests/data/ops/exceptions.txt +++ b/tools/hrw4u/tests/data/ops/exceptions.txt @@ -9,3 +9,5 @@ header_value_context.input: u4wrh http_cntl_valid_bools.input: hrw4u # Literal JSON blocks compile to native header_rewrite escaping. json-body.input: hrw4u +# The reverse normalizes a bool argument's spelling (FALSE -> false) +bool-spelling.input: hrw4u diff --git a/tools/hrw4u/tests/data/ops/number-spelling.input.txt b/tools/hrw4u/tests/data/ops/number-spelling.input.txt new file mode 100644 index 00000000000..05680bf58a4 --- /dev/null +++ b/tools/hrw4u/tests/data/ops/number-spelling.input.txt @@ -0,0 +1,7 @@ +# The emitter echoes a number's digits, so the AST cannot normalize one anywhere: +# 007 and 7 are different bytes once a number reaches a header value. +REMAP { + if random(0100) > 007 { + inbound.req.X-Count = 007; + } +} diff --git a/tools/hrw4u/tests/data/ops/number-spelling.output.txt b/tools/hrw4u/tests/data/ops/number-spelling.output.txt new file mode 100644 index 00000000000..6988ba47e22 --- /dev/null +++ b/tools/hrw4u/tests/data/ops/number-spelling.output.txt @@ -0,0 +1,5 @@ +# The emitter echoes a number's digits, so the AST cannot normalize one anywhere: +# 007 and 7 are different bytes once a number reaches a header value. +cond %{REMAP_PSEUDO_HOOK} [AND] +cond %{RANDOM:0100} >007 + set-header X-Count 007 diff --git a/tools/hrw4u/tests/data/procedures/local-bare-param.input.txt b/tools/hrw4u/tests/data/procedures/local-bare-param.input.txt new file mode 100644 index 00000000000..1cbee7941d1 --- /dev/null +++ b/tools/hrw4u/tests/data/procedures/local-bare-param.input.txt @@ -0,0 +1,7 @@ +procedure local::tag($name) { + inbound.req.X-Tag = $name; +} + +REMAP { + local::tag("hello"); +} diff --git a/tools/hrw4u/tests/data/procedures/local-bare-param.output.txt b/tools/hrw4u/tests/data/procedures/local-bare-param.output.txt new file mode 100644 index 00000000000..82acc54b246 --- /dev/null +++ b/tools/hrw4u/tests/data/procedures/local-bare-param.output.txt @@ -0,0 +1,2 @@ +cond %{REMAP_PSEUDO_HOOK} [AND] + set-header X-Tag hello diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.error.txt b/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.error.txt new file mode 100644 index 00000000000..4aa2d20b1d5 --- /dev/null +++ b/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.error.txt @@ -0,0 +1 @@ +'else' is denied by sandbox policy (language) diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.input.txt b/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.input.txt new file mode 100644 index 00000000000..dcaf3a6d84d --- /dev/null +++ b/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.input.txt @@ -0,0 +1,6 @@ +REMAP { + if inbound.req.X-Foo == "a" { + inbound.req.X-Result = "yes"; + } else { + } +} diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.sandbox.yaml b/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.sandbox.yaml new file mode 100644 index 00000000000..58c2bde66b0 --- /dev/null +++ b/tools/hrw4u/tests/data/sandbox/denied-language-else-empty.sandbox.yaml @@ -0,0 +1,6 @@ +sandbox: + message: "Feature denied by sandbox policy. Contact platform team." + + deny: + language: + - else diff --git a/tools/hrw4u/tests/data/sandbox/per-test-sandbox.input.txt b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.input.txt index bdfdf647a71..5ccf2f3bdce 100644 --- a/tools/hrw4u/tests/data/sandbox/per-test-sandbox.input.txt +++ b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.input.txt @@ -1,3 +1,5 @@ TXN_START { - inbound.req.X-Foo = "test"; + if inbound.ip in {10.0.0.0/8} { + counter("txn.internal"); + } } diff --git a/tools/hrw4u/tests/test_ast_roundtrip.py b/tools/hrw4u/tests/test_ast_roundtrip.py new file mode 100644 index 00000000000..a0e8c411b34 --- /dev/null +++ b/tools/hrw4u/tests/test_ast_roundtrip.py @@ -0,0 +1,100 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The AST must carry everything the emitter reads out of the source. + +Hand-written cases only catch losses someone thought of; an empty `else { }`, a dropped +comment and a `TRUE`/`true` spelling all shipped because nobody did. So instead of naming +distinctions, run the whole corpus through the AST and back and require the compiled +config to be unchanged: whatever the AST drops, the config loses too. + + corpus .hrw4u --parse--> AST --unparse--> regenerated .hrw4u + | | + emit emit + | | + v v + config <----------- must match ------------> config + +The comparison is the compiled config, never the regenerated source text. An AST holds no +whitespace, indentation or blank lines, and the emitter reads none of them -- it even +indents a preserved comment by nesting depth rather than by the column it came from. So +demanding byte-identical source would fail on almost every input for reasons that change +no output, and satisfying it would mean turning the AST back into a CST. + +A companion test asserts the corpus reaches every grammar rule, so a rule with no fixture +is reported rather than silently unguarded. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from antlr4 import ParserRuleContext + +import ast_unparse +import utils +from hrw4u.ast_visitor import ASTVisitor +from hrw4u.hrw4uParser import hrw4uParser +from hrw4u.visitor import HRW4UVisitor + +CORPUS = Path("tests/data") + + +def _case_id(input_file: Path) -> str: + return f"{input_file.parent.name}/{input_file.name.removesuffix('.input.txt')}" + + +def _cases() -> list[pytest.param]: + files = (f for f in sorted(CORPUS.glob("*/*.input.txt")) if ".fail." not in f.name) + return [pytest.param(f, id=_case_id(f)) for f in files] + + +def _compile(text: str, input_file: Path) -> list[str]: + _, tree = utils.parse_input_text(text) + visitor = HRW4UVisitor(filename=str(input_file), proc_search_paths=[input_file.parent / "procs"]) + return visitor.visit(tree) + + +@pytest.mark.parametrize("input_file", _cases()) +def test_emitted_config_survives_a_round_trip_through_the_ast(input_file: Path) -> None: + source = input_file.read_text() + _, tree = utils.parse_input_text(source) + regenerated = ast_unparse.unparse(ASTVisitor().visit(tree)) + + expected = _compile(source, input_file) + # An empty config would pass no matter what the AST drops. + assert expected, f"{input_file} compiles to nothing; it cannot witness a round trip" + assert _compile(regenerated, input_file) == expected, ( + f"{input_file}: the AST lost something the emitter reads.\n" + f"--- regenerated hrw4u ---\n{regenerated}") + + +def test_the_corpus_reaches_every_grammar_rule() -> None: + reached: set[str] = set() + + def walk(ctx) -> None: + if isinstance(ctx, ParserRuleContext): + reached.add(hrw4uParser.ruleNames[ctx.getRuleIndex()]) + for child in ctx.getChildren(): + walk(child) + + for param in _cases(): + _, tree = utils.parse_input_text(param.values[0].read_text()) + walk(tree) + + missing = set(hrw4uParser.ruleNames) - reached + assert not missing, f"no corpus input exercises: {', '.join(sorted(missing))}" diff --git a/tools/hrw4u/tests/test_ast_visitor.py b/tools/hrw4u/tests/test_ast_visitor.py index ec919d1f060..2df26b5b5da 100644 --- a/tools/hrw4u/tests/test_ast_visitor.py +++ b/tools/hrw4u/tests/test_ast_visitor.py @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest + from hrw4u.ast_nodes import * from utils import parse_input_text from hrw4u.ast_visitor import ASTVisitor @@ -39,12 +41,26 @@ def test_bool_value(self): ast = _build('SEND_RESPONSE {\n http.cntl.TXN_DEBUG = true;\n}') a = ast.body[0].body[0] assert isinstance(a, Assignment) - assert a.value is True + assert a.value == BoolValue(raw="true") + + def test_bool_assignment_keeps_source_spelling(self): + """The emitter echoes the spelling back, so the RHS cannot normalize to a plain bool.""" + upper = _build('SEND_RESPONSE {\n http.cntl.TXN_DEBUG = TRUE;\n}').body[0].body[0] + lower = _build('SEND_RESPONSE {\n http.cntl.TXN_DEBUG = true;\n}').body[0].body[0] + assert upper.value == BoolValue(raw="TRUE") + assert lower.value == BoolValue(raw="true") + + def test_bool_keeps_source_spelling_outside_an_assignment(self): + """A procedure default is bound raw into its use site, so it echoes its spelling too.""" + src = 'procedure local::p($on=true, $off=FALSE) {\n set-debug();\n}\nREMAP {\n set-debug();\n}' + pd = _build(src).body[0] + assert pd.params[0].default == BoolValue(raw="true") + assert pd.params[1].default == BoolValue(raw="FALSE") def test_int_value(self): ast = _build('REMAP {\n http.cntl.INTERCEPT_RETRY = 1;\n}') a = ast.body[0].body[0] - assert a.value == 1 + assert a.value == NumberValue(raw="1") def test_plus_equals(self): ast = _build('REMAP {\n inbound.req.X-Foo += "extra";\n}') @@ -57,6 +73,16 @@ def test_ip_value(self): assert isinstance(a, Assignment) assert a.value == IPValue(raw="10.0.0.1") + def test_ident_value(self): + src = 'VARS {\n a: bool;\n b: bool;\n}\nREMAP {\n b = a;\n}' + a = _build(src).body[1].body[0] + assert isinstance(a, Assignment) + assert a.value == IdentValue(raw="a") + + def test_iprange_value(self): + a = _build('REMAP {\n inbound.req.X = {1.2.3.4, 5.6.7.8};\n}').body[0].body[0] + assert a.value == IpRangeValue(raw="{1.2.3.4,5.6.7.8}") + def test_param_ref_value(self): src = 'procedure local::stamp($tag) {\n inbound.req.X-Stamp = $tag;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) @@ -95,15 +121,15 @@ def test_break(self): class TestSections: - def test_comments_in_section_body_skipped(self): + def test_comments_in_section_body_preserved(self): src = 'REMAP {\n # a comment\n set-debug();\n # another comment\n}' ast = _build(src) - assert len(ast.body[0].body) == 1 + assert len(ast.body[0].body) == 3 - def test_comments_in_block_skipped(self): + def test_comments_in_block_preserved(self): src = 'REMAP {\n if true {\n # comment\n set-debug();\n }\n}' ast = _build(src) - assert len(ast.body[0].body[0].body) == 1 + assert len(ast.body[0].body[0].body) == 2 def test_section_type(self): ast = _build('REMAP {\n set-debug();\n}') @@ -138,12 +164,12 @@ def test_item_ordering(self): class TestVarSections: - def test_comments_in_var_section_skipped(self): + def test_comments_in_var_section_preserved(self): src = 'VARS {\n # comment\n x: bool;\n # another\n y: int;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) vs = ast.body[0] assert isinstance(vs, VarSection) - assert len(vs.declarations) == 2 + assert len(vs.declarations) == 4 def test_txn_scope(self): src = 'VARS {\n flag: bool;\n}\nREMAP {\n set-debug();\n}' @@ -200,7 +226,7 @@ def test_default_param(self): pd = ast.body[0] assert isinstance(pd, ProcedureDecl) assert pd.params[0].name == "ttl" - assert pd.params[0].default == 300 + assert pd.params[0].default == NumberValue(raw="300") def test_body(self): src = ('procedure local::multi() {\n inbound.req.X = "a";\n' @@ -237,7 +263,7 @@ def test_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path in ["a", "b"] {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) assert cond.operator == "in" - assert cond.right == (LiteralStringValue(raw="a"), LiteralStringValue(raw="b")) + assert cond.right == SetValue(raw='"a","b"') def test_not_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path !in ["a"] {\n set-debug();\n }\n}') @@ -248,7 +274,7 @@ def test_in_iprange(self): cond = self._first_condition('REMAP {\n if inbound.ip in {10.0.0.0/8} {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) assert cond.operator == "in" - assert cond.right == (IPValue(raw="10.0.0.0/8"),) + assert cond.right == IpRangeValue(raw="{10.0.0.0/8}") def test_modifiers(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo == "bar" with NOCASE {\n set-debug();\n }\n}') @@ -265,7 +291,7 @@ def test_function_call_comparable(self): assert isinstance(cond, Comparison) assert isinstance(cond.left, FunctionCall) assert cond.left.name == "url" - assert cond.left.args == (True,) + assert cond.left.args == (BoolValue(raw="true"),) def test_bool_literal_true(self): cond = self._first_condition('REMAP {\n if true {\n set-debug();\n }\n}') @@ -312,13 +338,19 @@ def test_greater_than_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.Content-Length > 1000 {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) assert cond.operator == ">" - assert cond.right == 1000 + assert cond.right == NumberValue(raw="1000") def test_less_than_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.Content-Length < 500 {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) assert cond.operator == "<" - assert cond.right == 500 + assert cond.right == NumberValue(raw="500") + + def test_comparison_rhs_keeps_bool_spelling(self): + """`== TRUE` emits `=TRUE`, so normalizing the RHS would change the config.""" + cond = self._first_condition('REMAP {\n if inbound.req.X-Debug == TRUE {\n set-debug();\n }\n}') + assert isinstance(cond, Comparison) + assert cond.right == BoolValue(raw="TRUE") def test_neq_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo != "bar" {\n set-debug();\n }\n}') @@ -328,9 +360,25 @@ def test_neq_comparison(self): def test_parenthesized_condition(self): cond = self._first_condition('REMAP {\n if (inbound.req.X-Foo == "bar") {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == "==" - assert cond.right == LiteralStringValue(raw="bar") + assert isinstance(cond, Group) + assert isinstance(cond.inner, Comparison) + assert cond.inner.operator == "==" + assert cond.inner.right == LiteralStringValue(raw="bar") + + def test_parens_are_kept(self): + """A parenthesized factor becomes cond %{GROUP}.""" + grouped = self._first_condition('REMAP {\n if (true) {\n set-debug();\n }\n}') + bare = self._first_condition('REMAP {\n if true {\n set-debug();\n }\n}') + assert isinstance(grouped, Group) + assert isinstance(grouped.inner, BoolLiteral) + assert isinstance(bare, BoolLiteral) + + def test_set_and_iprange_are_distinguishable(self): + """`in [1.2.3.4]` emits (1.2.3.4) but `in {1.2.3.4}` emits {1.2.3.4}.""" + as_set = self._first_condition('REMAP {\n if inbound.ip in [1.2.3.4] {\n set-debug();\n }\n}') + as_range = self._first_condition('REMAP {\n if inbound.ip in {1.2.3.4} {\n set-debug();\n }\n}') + assert as_set.right == SetValue(raw="1.2.3.4") + assert as_range.right == IpRangeValue(raw="{1.2.3.4}") def test_and_binds_tighter_than_or(self): # a || b && c should parse as a || (b && c) @@ -370,9 +418,9 @@ def test_not_comparison_with_or(self): assert isinstance(cond, LogicalOp) assert cond.operator == "||" assert isinstance(cond.left, NotOp) - assert isinstance(cond.left.operand, Comparison) - assert cond.left.operand.left == IdentValue(raw="inbound.req.X-A") - assert cond.left.operand.right == LiteralStringValue(raw="x") + assert isinstance(cond.left.operand, Group) + assert cond.left.operand.inner.left == IdentValue(raw="inbound.req.X-A") + assert cond.left.operand.inner.right == LiteralStringValue(raw="x") assert isinstance(cond.right, Comparison) assert cond.right.left == IdentValue(raw="inbound.req.X-B") @@ -397,10 +445,10 @@ def test_parens_override_precedence(self): ' set-debug();\n }\n}') assert isinstance(cond, LogicalOp) assert cond.operator == "&&" - assert isinstance(cond.left, LogicalOp) - assert cond.left.operator == "||" - assert cond.left.left.left == IdentValue(raw="inbound.req.X-A") - assert cond.left.right.left == IdentValue(raw="inbound.req.X-B") + assert isinstance(cond.left, Group) + assert cond.left.inner.operator == "||" + assert cond.left.inner.left.left == IdentValue(raw="inbound.req.X-A") + assert cond.left.inner.right.left == IdentValue(raw="inbound.req.X-B") assert isinstance(cond.right, Comparison) assert cond.right.left == IdentValue(raw="inbound.req.X-C") @@ -413,8 +461,8 @@ def test_nested_parens_with_not(self): assert isinstance(cond, LogicalOp) assert cond.operator == "&&" assert isinstance(cond.left, NotOp) - assert isinstance(cond.left.operand, LogicalOp) - assert cond.left.operand.operator == "||" + assert isinstance(cond.left.operand, Group) + assert cond.left.operand.inner.operator == "||" assert isinstance(cond.right, Comparison) assert cond.right.left == IdentValue(raw="inbound.req.X-C") @@ -433,8 +481,32 @@ def test_if_else(self): src = 'REMAP {\n if true {\n inbound.req.X = "a";\n } else {\n inbound.req.X = "b";\n }\n}' ast = _build(src) ib = ast.body[0].body[0] + assert ib.has_else is True assert len(ib.else_body) == 1 + def test_empty_else_is_distinguishable_from_no_else(self): + """Gating on else_body alone lets a sandbox policy denying 'else' be evaded.""" + no_else = _build('REMAP {\n if true {\n inbound.req.X = "y";\n }\n}').body[0].body[0] + empty_else = _build('REMAP {\n if true {\n inbound.req.X = "y";\n } else {\n }\n}').body[0].body[0] + assert no_else.has_else is False + assert empty_else.has_else is True + assert empty_else.else_body == () + + def test_empty_else_after_elif_sets_has_else(self): + src = ( + 'REMAP {\n if inbound.req.X == "a" {\n set-debug();\n' + ' } elif inbound.req.X == "b" {\n set-debug();\n' + ' } else {\n }\n}') + ib = _build(src).body[0].body[0] + assert len(ib.elif_branches) == 1 + assert ib.has_else is True + assert ib.else_body == () + + def test_has_else_is_required(self): + """A default would silently mean "no else" at any node-rebuilding site.""" + with pytest.raises(TypeError): + IfBlock(condition=BoolLiteral(value=True, line=1), body=(), elif_branches=(), else_body=(), line=1) + def test_if_elif_else(self): src = ( 'SEND_RESPONSE {\n if inbound.url.path == "foo" {\n' @@ -686,8 +758,8 @@ def test_http_cntl_booleans(self): }''' ast = _build(src) body = ast.body[0].body - assert body[0].value is True - assert body[1].value is False + assert body[0].value == BoolValue(raw="true") + assert body[1].value == BoolValue(raw="FALSE") def test_ip_range_condition(self): """Validates IP range handling from tests/data/conds/ip.input.txt.""" @@ -700,7 +772,7 @@ def test_ip_range_condition(self): cond = ast.body[0].body[0].condition assert isinstance(cond, Comparison) assert cond.operator == "in" - assert len(cond.right) == 2 + assert cond.right == IpRangeValue(raw="{192.168.0.0/16,10.0.0.0/8}") def test_set_membership_with_modifier(self): """From tests/data/conds/in-sets.input.txt.""" @@ -713,7 +785,7 @@ def test_set_membership_with_modifier(self): cond = ast.body[0].body[0].condition assert isinstance(cond, Comparison) assert cond.operator == "in" - assert cond.right == (LiteralStringValue(raw="php"), LiteralStringValue(raw="php3"), LiteralStringValue(raw="php4")) + assert cond.right == SetValue(raw='"php","php3","php4"') assert cond.modifiers == ("EXT",) def test_debug_pattern_for_lint_rules(self): @@ -733,8 +805,30 @@ def test_debug_pattern_for_lint_rules(self): # TXN_DEBUG assignment with True assert isinstance(body[1], Assignment) assert body[1].target == Target.from_dotted("http.cntl.TXN_DEBUG") - assert body[1].value is True + assert body[1].value == BoolValue(raw="true") # Regular assignment (not flagged) assert isinstance(body[2], Assignment) assert body[2].target.namespace == "inbound.req" + + +class TestComments: + + def test_top_level_comment_preserved(self): + ast = _build('# hello\nREMAP {\n set-debug();\n}') + assert ast.body[0] == Comment(text="# hello", line=1) + + def test_comment_in_section_body_keeps_position(self): + body = _build('REMAP {\n # first\n set-debug();\n}').body[0].body + assert isinstance(body[0], Comment) + assert body[0].text == "# first" + assert isinstance(body[1], FunctionCall) + + def test_comment_in_block(self): + ast = _build('REMAP {\n if inbound.status > 399 {\n # why\n set-debug();\n }\n}') + assert isinstance(ast.body[0].body[0].body[0], Comment) + + def test_comment_in_vars_section(self): + decls = _build('VARS {\n # a counter\n hits: int8;\n}').body[0].declarations + assert isinstance(decls[0], Comment) + assert isinstance(decls[1], VarDecl)