diff --git a/internal/route/rules/block_parser_bench_test.go b/internal/route/rules/block_parser_bench_test.go index 1550a1c5..6f5b574c 100644 --- a/internal/route/rules/block_parser_bench_test.go +++ b/internal/route/rules/block_parser_bench_test.go @@ -11,22 +11,22 @@ default { header X-Test-Header { set header X-Remote-Type public - @remote 127.0.0.1 | remote 192.168.0.0/16 { + remote 127.0.0.1 | remote 192.168.0.0/16 { set header X-Remote-Type private } } path glob(/api/admin/*) { - @cookie session-id { + cookie session-id { set header X-Session-ID $cookie(session-id) } } !remote 192.168.0.0/16 { - @!header X-User-Role admin & !header X-User-Role user { + !header X-User-Role admin & !header X-User-Role user { error 403 "Access denied" } elif remote 127.0.0.1 { - @header X-User-Role staff { + header X-User-Role staff { set header X-User-Role staff } } else { diff --git a/internal/route/rules/block_parser_test.go b/internal/route/rules/block_parser_test.go index 30a9de67..2407ccc6 100644 --- a/internal/route/rules/block_parser_test.go +++ b/internal/route/rules/block_parser_test.go @@ -176,7 +176,7 @@ func TestParseBlockRules_NestedBlocks(t *testing.T) { rules := testParseRules(t, ` header X-Test-Header { set header X-Remote-Type public - @remote 127.0.0.1 | remote 192.168.0.0/16 { + remote 127.0.0.1 | remote 192.168.0.0/16 { set header X-Remote-Type private } }`) @@ -226,7 +226,7 @@ func TestParseBlockRules_NestedBlocks_ElifElse(t *testing.T) { rules := testParseRules(t, ` header X-Test-Header { set header X-Mode outer - @method GET { + method GET { set header X-Mode get } elif method POST { set header X-Mode post @@ -289,7 +289,7 @@ default { func TestParseBlockRules_NestedBlocks_ElifMustBeSameLine(t *testing.T) { err := testParseRulesError(t, `header X-Test-Header { - @method GET { + method GET { set header X-Mode get } elif method POST { @@ -301,7 +301,7 @@ func TestParseBlockRules_NestedBlocks_ElifMustBeSameLine(t *testing.T) { func TestParseBlockRules_NestedBlocks_ElseMustBeLastOnLine(t *testing.T) { err := testParseRulesError(t, `header X-Test-Header { - @method GET { + method GET { set header X-Mode get } else { set header X-Mode other @@ -313,7 +313,7 @@ func TestParseBlockRules_NestedBlocks_ElseMustBeLastOnLine(t *testing.T) { func TestParseBlockRules_NestedBlocks_MultipleElse(t *testing.T) { err := testParseRulesError(t, `header X-Test-Header { - @method GET { + method GET { set header X-Mode get } else { set header X-Mode other @@ -328,7 +328,7 @@ func TestParseBlockRules_NestedBlocks_MultipleElse(t *testing.T) { func TestParseBlockRules_NestedBlocks_ElifMissingOnExpr(t *testing.T) { err := testParseRulesError(t, `header X-Test-Header { - @method GET { + method GET { set header X-Mode get } elif { set header X-Mode post @@ -337,3 +337,54 @@ func TestParseBlockRules_NestedBlocks_ElifMissingOnExpr(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "expected on-expr after 'elif'") } + +func TestParseBlockRules_NestedBlocks_LineEndingBraceHeuristic(t *testing.T) { + rules := testParseRules(t, `{ + set header X-Literal "{" +}`) + require.Len(t, rules, 1) + require.Len(t, rules[0].Do.pre, 1) + _, ok := rules[0].Do.pre[0].(Handler) + require.True(t, ok) +} + +func TestParseBlockRules_NestedBlocks_LineEndingBraceWithTrailingSpaces(t *testing.T) { + rules := testParseRules(t, `header X-Test-Header { + method GET { + set header X-Mode get + } +}`) + require.Len(t, rules, 1) + require.Len(t, rules[0].Do.pre, 1) + ifCmd, ok := rules[0].Do.pre[0].(IfBlockCommand) + require.True(t, ok) + assert.Equal(t, "method GET", ifCmd.On.raw) +} + +func TestParseBlockRules_NestedBlocks_LineEndingBraceWithTrailingComment(t *testing.T) { + rules := testParseRules(t, `header X-Test-Header { + method GET { // GET branch + set header X-Mode get + } else { # fallback branch + set header X-Mode other + } +}`) + require.Len(t, rules, 1) + require.Len(t, rules[0].Do.pre, 1) + + ifCmd, ok := rules[0].Do.pre[0].(IfElseBlockCommand) + require.True(t, ok) + require.Len(t, ifCmd.Ifs, 1) + assert.Equal(t, "method GET", ifCmd.Ifs[0].On.raw) + require.NotNil(t, ifCmd.Else) +} + +func TestParseBlockRules_NestedBlocks_LineEndingBraceInterpretsAsBlock(t *testing.T) { + err := testParseRulesError(t, `{ + set header X-Bad { + set header X-Test fail + } +}`) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid `rule.on` target") +} diff --git a/internal/route/rules/do_blocks.go b/internal/route/rules/do_blocks.go index 32df3d4d..96af2060 100644 --- a/internal/route/rules/do_blocks.go +++ b/internal/route/rules/do_blocks.go @@ -13,7 +13,7 @@ import ( // // Syntax (within a rule do block): // -// @ { } +// { } // // Semantics: // - Evaluated in the same phase the parent rule runs. @@ -53,7 +53,7 @@ func (c IfBlockCommand) Phase() PhaseFlag { // // Syntax (within a rule do block): // -// @ { } elif { } ... else { } +// { } elif { } ... else { } // // NOTE: `elif`/`else` must appear on the same line as the preceding closing brace (`}`), // e.g. `} elif ... {` and `} else {`. @@ -113,9 +113,9 @@ func skipSameLineSpace(src string, pos int) int { return pos } -func parseAtBlockChain(src string, atPos int) (CommandHandler, int, error) { +func parseAtBlockChain(src string, blockPos int) (CommandHandler, int, error) { length := len(src) - headerStart := atPos + 1 + headerStart := blockPos parseBranch := func(onExpr string, bodyStart int, bodyEnd int) (RuleOn, []CommandHandler, error) { var on RuleOn @@ -140,11 +140,14 @@ func parseAtBlockChain(src string, atPos int) (CommandHandler, int, error) { if herr != nil { return nil, 0, herr } + if onExpr == "" { + return nil, 0, ErrInvalidBlockSyntax.Withf("expected on-expr before '{'") + } if bracePos >= length || src[bracePos] != '{' { return nil, 0, ErrInvalidBlockSyntax.Withf("expected '{' after nested block header") } - // Parse first @ { ... } + // Parse first { ... } p := bracePos bodyStart := p + 1 bodyEnd, ferr := findMatchingBrace(src, &p, bodyStart) @@ -288,10 +291,56 @@ func parseAtBlockChain(src string, atPos int) (CommandHandler, int, error) { return IfBlockCommand{On: ifs[0].On, Do: ifs[0].Do}, p, nil } -// parseDoWithBlocks parses a do-body containing plain command lines and nested @-blocks. +func lineEndsWithUnquotedOpenBrace(src string, lineStart int, lineEnd int) bool { + quote := byte(0) + lastSignificant := byte(0) + atLineStart := true + prevIsSpace := true + + for i := lineStart; i < lineEnd; i++ { + c := src[i] + if quote != 0 { + if c == '\\' && i+1 < lineEnd { + i++ + continue + } + if c == quote { + quote = 0 + } + atLineStart = false + prevIsSpace = false + continue + } + if quoteChars[c] { + quote = c + atLineStart = false + prevIsSpace = false + continue + } + if c == '#' && (atLineStart || prevIsSpace) { + break + } + if c == '/' && i+1 < lineEnd { + n := rune(src[i+1]) + if (atLineStart || prevIsSpace) && (n == '/' || n == '*') { + break + } + } + if unicode.IsSpace(rune(c)) { + prevIsSpace = true + continue + } + lastSignificant = c + atLineStart = false + prevIsSpace = false + } + return quote == 0 && lastSignificant == '{' +} + +// parseDoWithBlocks parses a do-body containing plain command lines and nested blocks. // It returns the outer command handlers and the require phase. // -// A nested block is only recognized when '@' is the first non-space character on a line. +// A nested block is recognized when a line ends with an unquoted '{' (ignoring trailing whitespace). func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { pos := 0 length := len(src) @@ -351,7 +400,12 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { linePos++ } - if linePos < length && src[linePos] == '@' { + lineEnd := linePos + for lineEnd < length && src[lineEnd] != '\n' { + lineEnd++ + } + + if linePos < length && lineEndsWithUnquotedOpenBrace(src, linePos, lineEnd) { h, next, err := parseAtBlockChain(src, linePos) if err != nil { return nil, err @@ -363,7 +417,7 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { } // Not a nested block; parse the rest of this line as a command. - lineEnd := pos + lineEnd = pos for lineEnd < length && src[lineEnd] != '\n' { lineEnd++ } diff --git a/internal/route/rules/http_flow_block_test.go b/internal/route/rules/http_flow_block_test.go index 7be1c5eb..33191de8 100644 --- a/internal/route/rules/http_flow_block_test.go +++ b/internal/route/rules/http_flow_block_test.go @@ -457,7 +457,7 @@ func TestHTTPFlow_NestedBlocks_RemoteOverride(t *testing.T) { err := parseRules(` header X-Test-Header { set header X-Remote-Type public - @remote 127.0.0.1 | remote 192.168.0.0/16 { + remote 127.0.0.1 | remote 192.168.0.0/16 { set header X-Remote-Type private } } @@ -495,7 +495,7 @@ func TestHTTPFlow_NestedBlocks_ElifElse(t *testing.T) { var rules Rules err := parseRules(` header X-Test-Header { - @method GET { + method GET { set header X-Mode get } elif method POST { set header X-Mode post @@ -552,7 +552,7 @@ func TestHTTPFlow_NestedBlocks_TerminatingActionStopsFlow(t *testing.T) { err := parseRules(` path / { set header X-Pre pre - @header X-Block { + header X-Block { error 403 "blocked" } set resp_header X-After should-not-run @@ -593,7 +593,7 @@ func TestHTTPFlow_NestedBlocks_InResponseRule_ModifiesResponseByRequestMethod(t err := parseRules(` { set header X-Method "should-be-overridden" - @method POST { + method POST { set header X-Method "post" } elif method GET { set header X-Method "get" diff --git a/internal/route/rules/rules_test.go b/internal/route/rules/rules_test.go index b1f923fc..b507a891 100644 --- a/internal/route/rules/rules_test.go +++ b/internal/route/rules/rules_test.go @@ -79,7 +79,7 @@ header Host example.com { name: "same condition with terminating handler inside if block", rules: ` header Host example.com { - @default { + default { error 404 "not found" } } @@ -94,7 +94,7 @@ header Host example.com { name: "same condition with terminating handler across if else block", rules: ` header Host example.com { - @method GET { + method GET { error 404 "not found" } else { redirect https://example.com @@ -111,7 +111,7 @@ header Host example.com { name: "same condition with non terminating if branch in if else block", rules: ` header Host example.com { - @method GET { + method GET { set resp_header X-Test first } else { error 404 "not found"