feat(rules): support multiline or |

treat lines ending with unquoted `|` or `&` as continued
conditions in `do` block headers so nested blocks parse correctly
across line breaks.

update `on` condition splitting to avoid breaking on newlines that
follow an unescaped trailing pipe, while still respecting quotes,
escapes, and bracket nesting.

add coverage for multiline `|`/`&` continuations in `do` parsing,
`splitAnd`, `parseOn`, and HTTP flow nested block behavior.
This commit is contained in:
yusing
2026-02-26 16:38:54 +08:00
parent c002055892
commit a0adc51269
6 changed files with 217 additions and 80 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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])
})
}
}

View File

@@ -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
}
}

View File

@@ -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, &quote, &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, &quote, &brackets) {
continue
}
if s[i] == '|' && brackets == 0 {
if part := strings.TrimSpace(s[start:i]); part != "" {
fn(part)
}
start = i + 1
}
}
if start < len(s) {

View File

@@ -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)
}