diff --git a/internal/route/rules/README.md b/internal/route/rules/README.md index e64072fa..c8393ca7 100644 --- a/internal/route/rules/README.md +++ b/internal/route/rules/README.md @@ -309,7 +309,8 @@ nested_block := on_expr ws* '{' do_body '}' Notes: -- A nested block is recognized when a line ends with an unquoted `{` (ignoring trailing whitespace). +- A nested block is recognized when a logical header ends with an unquoted `{`. +- Logical headers can continue to the next line when the current line ends with `|` or `&`. - `on_expr` uses the same syntax as rule `on` (supports `|`, `&`, quoting/backticks, matcher functions, etc.). - The nested block executes **in sequence**, at the point where it appears in the parent `do` list. - Nested blocks are evaluated in the same phase the parent rule runs (no special phase promotion). @@ -424,6 +425,15 @@ path !glob("/public/*") # OR within a line method GET | method POST + +# OR across multiple lines (line continuation) +method GET | +method POST | +method PUT + +# AND across multiple lines +header Connection Upgrade & +header Upgrade websocket ``` ### Variable Substitution diff --git a/internal/route/rules/do_blocks.go b/internal/route/rules/do_blocks.go index 056ce6ed..412c8184 100644 --- a/internal/route/rules/do_blocks.go +++ b/internal/route/rules/do_blocks.go @@ -292,6 +292,20 @@ func parseAtBlockChain(src string, blockPos int) (CommandHandler, int, error) { } func lineEndsWithUnquotedOpenBrace(src string, lineStart int, lineEnd int) bool { + return lineEndsWithUnquotedToken(src, lineStart, lineEnd) == '{' +} + +func lineContinuationOperator(src string, lineStart int, lineEnd int) byte { + token := lineEndsWithUnquotedToken(src, lineStart, lineEnd) + switch token { + case '|', '&': + return token + default: + return 0 + } +} + +func lineEndsWithUnquotedToken(src string, lineStart int, lineEnd int) byte { quote := byte(0) lastSignificant := byte(0) atLineStart := true @@ -334,13 +348,22 @@ func lineEndsWithUnquotedOpenBrace(src string, lineStart int, lineEnd int) bool atLineStart = false prevIsSpace = false } - return quote == 0 && lastSignificant == '{' + if quote != 0 { + return 0 + } + return 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 recognized when a line ends with an unquoted '{' (ignoring trailing whitespace). +// A nested block is recognized when a logical header ends with an unquoted '{'. +// Logical headers may span lines using trailing '|' or '&', for example: +// +// remote 127.0.0.1 | +// remote 192.168.0.0/16 { +// set header X-Remote-Type private +// } func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { pos := 0 length := len(src) @@ -400,12 +423,38 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { linePos++ } - lineEnd := linePos - for lineEnd < length && src[lineEnd] != '\n' { - lineEnd++ + logicalEnd := linePos + for logicalEnd < length && src[logicalEnd] != '\n' { + logicalEnd++ } - if linePos < length && lineEndsWithUnquotedOpenBrace(src, linePos, lineEnd) { + for linePos < length && lineContinuationOperator(src, linePos, logicalEnd) != 0 { + nextPos := logicalEnd + if nextPos < length && src[nextPos] == '\n' { + nextPos++ + } + for nextPos < length { + c := rune(src[nextPos]) + if c == '\n' { + nextPos++ + continue + } + if c == '\r' || unicode.IsSpace(c) { + nextPos++ + continue + } + break + } + if nextPos >= length { + break + } + logicalEnd = nextPos + for logicalEnd < length && src[logicalEnd] != '\n' { + logicalEnd++ + } + } + + if linePos < length && lineEndsWithUnquotedOpenBrace(src, linePos, logicalEnd) { h, next, err := parseAtBlockChain(src, linePos) if err != nil { return nil, err @@ -417,10 +466,10 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { } // Not a nested block; parse the rest of this line as a command. - if lerr := appendLineCommand(src[pos:lineEnd]); lerr != nil { + if lerr := appendLineCommand(src[pos:logicalEnd]); lerr != nil { return nil, lerr } - pos = lineEnd + pos = logicalEnd lineStart = true continue } diff --git a/internal/route/rules/do_blocks_test.go b/internal/route/rules/do_blocks_test.go index 4f740cb7..dbfe8542 100644 --- a/internal/route/rules/do_blocks_test.go +++ b/internal/route/rules/do_blocks_test.go @@ -71,3 +71,38 @@ func TestIfElseBlockCommandServeHTTP_ConditionalMatchedNilDoNotFallsThrough(t *t require.NoError(t, err) assert.False(t, elseCalled) } + +func TestParseDoWithBlocks_MultilineBlockHeaderContinuation(t *testing.T) { + tests := []struct { + name string + src string + }{ + { + name: "or continuation", + src: ` +remote 127.0.0.1 | +remote 192.168.0.0/16 { + set header X-Remote-Type private +} +`, + }, + { + name: "and continuation", + src: ` +method GET & +remote 127.0.0.1 { + set header X-Remote-Type private +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handlers, err := parseDoWithBlocks(tt.src) + require.NoError(t, err) + require.Len(t, handlers, 1) + require.IsType(t, IfBlockCommand{}, handlers[0]) + }) + } +} diff --git a/internal/route/rules/http_flow_block_test.go b/internal/route/rules/http_flow_block_test.go index c106cd28..30b87af8 100644 --- a/internal/route/rules/http_flow_block_test.go +++ b/internal/route/rules/http_flow_block_test.go @@ -456,7 +456,8 @@ 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 } } diff --git a/internal/route/rules/on.go b/internal/route/rules/on.go index 7c5e720f..6457a44d 100644 --- a/internal/route/rules/on.go +++ b/internal/route/rules/on.go @@ -505,62 +505,70 @@ var ( andSeps = [256]uint8{'&': 1, '\n': 1} ) -func indexAnd(s string) int { - for i := range s { - if andSeps[s[i]] != 0 { - return i - } - } - return -1 -} - -func countAnd(s string) int { - n := 0 - for i := range s { - if andSeps[s[i]] != 0 { - n++ - } - } - return n -} - -// splitAnd splits a string by "&" and "\n" with all spaces removed. -// empty strings are not included in the result. +// splitAnd splits a condition string into AND parts. +// It treats '&' and newline as AND separators, except when a line ends with +// an unescaped '|' (OR continuation), where the newline stays in the same part. +// Empty parts are omitted. func splitAnd(s string) []string { if s == "" { return []string{} } - n := countAnd(s) - a := make([]string, n+1) - i := 0 - for i < n { - end := indexAnd(s) - if end == -1 { - break + result := []string{} + forEachAndPart(s, func(part string) { + result = append(result, part) + }) + return result +} + +func lineEndsWithUnescapedPipe(s string, start, end int) bool { + for i := end - 1; i >= start; i-- { + if asciiSpace[s[i]] != 0 { + continue } - beg := 0 - // trim leading spaces - for beg < end && asciiSpace[s[beg]] != 0 { - beg++ + if s[i] != '|' { + return false } - // trim trailing spaces - next := end + 1 - for end-1 > beg && asciiSpace[s[end-1]] != 0 { - end-- + escapes := 0 + for j := i - 1; j >= start && s[j] == '\\'; j-- { + escapes++ } - // skip empty segments - if end > beg { - a[i] = s[beg:end] - i++ - } - s = s[next:] + return escapes%2 == 0 } - s = strings.TrimSpace(s) - if s != "" { - a[i] = s - i++ + return false +} + +func advanceSplitState(s string, i *int, quote *byte, brackets *int) bool { + c := s[*i] + if *quote != 0 { + if c == '\\' && *i+1 < len(s) { + *i++ + return true + } + if c == *quote { + *quote = 0 + } + return true } - return a[:i] + + switch c { + case '\\': + if *i+1 < len(s) { + *i++ + return true + } + case '"', '\'', '`': + *quote = c + return true + case '(': + *brackets++ + return true + case ')': + if *brackets > 0 { + *brackets-- + } + return true + } + return false } // splitPipe splits a string by "|" but respects quotes, brackets, and escaped characters. @@ -578,8 +586,26 @@ func splitPipe(s string) []string { } func forEachAndPart(s string, fn func(part string)) { + quote := byte(0) + brackets := 0 start := 0 + for i := 0; i <= len(s); i++ { + if i < len(s) { + c := s[i] + if advanceSplitState(s, &i, "e, &brackets) { + continue + } + + if c == '\n' { + if brackets > 0 || lineEndsWithUnescapedPipe(s, start, i) { + continue + } + } else if c != '&' || brackets > 0 { + continue + } + } + if i < len(s) && andSeps[s[i]] == 0 { continue } @@ -597,30 +623,14 @@ func forEachPipePart(s string, fn func(part string)) { start := 0 for i := 0; i < len(s); i++ { - switch s[i] { - case '\\': - if i+1 < len(s) { - i++ - } - case '"', '\'', '`': - if quote == 0 && brackets == 0 { - quote = s[i] - } else if s[i] == quote { - quote = 0 - } - case '(': - brackets++ - case ')': - if brackets > 0 { - brackets-- - } - case '|': - if quote == 0 && brackets == 0 { - if part := strings.TrimSpace(s[start:i]); part != "" { - fn(part) - } - start = i + 1 + if advanceSplitState(s, &i, "e, &brackets) { + continue + } + if s[i] == '|' && brackets == 0 { + if part := strings.TrimSpace(s[start:i]); part != "" { + fn(part) } + start = i + 1 } } if start < len(s) { diff --git a/internal/route/rules/on_internal_test.go b/internal/route/rules/on_internal_test.go index 8a43e791..cac307d9 100644 --- a/internal/route/rules/on_internal_test.go +++ b/internal/route/rules/on_internal_test.go @@ -1,6 +1,8 @@ package rules import ( + "net/http" + "net/url" "testing" gperr "github.com/yusing/goutils/errs" @@ -133,6 +135,16 @@ func TestSplitAnd(t *testing.T) { input: " rule1\nrule2 & rule3 ", want: []string{"rule1", "rule2", "rule3"}, }, + { + name: "newline_after_pipe_is_or_continuation", + input: "path /abc |\npath /bcd", + want: []string{"path /abc |\npath /bcd"}, + }, + { + name: "newline_after_pipe_with_spaces_is_or_continuation", + input: "path /abc | \n path /bcd", + want: []string{"path /abc | \n path /bcd"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -280,6 +292,11 @@ func TestParseOn(t *testing.T) { input: `method GET | path regex("^(_next/static|_next/image|favicon.ico).*$") | header Authorization`, wantErr: nil, }, + { + name: "pipe_multiline_continuation", + input: "path /abc |\npath /bcd |", + wantErr: nil, + }, } for _, tt := range tests { @@ -294,3 +311,18 @@ func TestParseOn(t *testing.T) { }) } } + +func TestRuleOnParse_MultilineOrContinuation(t *testing.T) { + var on RuleOn + err := on.Parse("path /abc |\npath /bcd |") + expect.NoError(t, err) + + w := http.ResponseWriter(nil) + reqABC := &http.Request{URL: &url.URL{Path: "/abc"}} + reqBCD := &http.Request{URL: &url.URL{Path: "/bcd"}} + reqXYZ := &http.Request{URL: &url.URL{Path: "/xyz"}} + + expect.Equal(t, on.Check(w, reqABC), true) + expect.Equal(t, on.Check(w, reqBCD), true) + expect.Equal(t, on.Check(w, reqXYZ), false) +}