Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,6 @@ jobs:
- name: Run tests
run: go test -v -race -timeout=10m ./...

fuzz:
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
cache: true

- name: Run fuzz tests (quick)
run: |
# Run each fuzz test for 10 seconds to catch obvious issues
go test -fuzz=FuzzParseScript -fuzztime=10s .
go test -fuzz=FuzzParseExpressions -fuzztime=10s .
go test -fuzz=FuzzEvalExpressions -fuzztime=10s .

lint:
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Fuzz Testing

on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
inputs:
fuzz-time:
description: "Time per fuzz target (Go duration format)"
required: false
default: "2m"

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true

permissions:
contents: read
issues: write

jobs:
fuzz:
name: Fuzz
runs-on: ubuntu-latest
timeout-minutes: 120

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true

- name: Set up Task
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v2

- name: Download Go modules
run: go mod download

- name: Run fuzz tests
run: task fuzz
env:
FUZZ_TIME: ${{ inputs.fuzz-time || '2m' }}

- name: Upload crash corpus
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: fuzz-crashes
path: "**/testdata/fuzz/**"
if-no-files-found: ignore

- name: Create issue on failure
if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: |
gh issue create \
--title "Nightly fuzz: crash found ($(date -u +%Y-%m-%d))" \
--label "bug" \
--body "$(cat <<'EOF'
The nightly fuzz run found a crash. The failing corpus entry is attached as an artifact on the workflow run.

**Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

Download the `fuzz-crashes` artifact from that run and place the corpus file into the appropriate `testdata/fuzz/FuzzName/` directory to reproduce locally:

```
go test -run FuzzName/corpus_filename .
```
EOF
)"
Comment thread
wizzomafizzo marked this conversation as resolved.
25 changes: 25 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,28 @@ tasks:
desc: Run tests
cmds:
- go test -race ./...

fuzz:
desc: Run fuzz tests (default 30s per test, use FUZZ_TIME to override)
vars:
FUZZ_TIME: '{{default "30s" .FUZZ_TIME}}'
cmds:
- |
exit_code=0
targets=(
"FuzzParseScript"
"FuzzParseExpressions"
"FuzzEvalExpressions"
"FuzzParseTagFilters"
"FuzzCommandString"
)
for target in "${targets[@]}"; do
echo "=== ${target} ==="
if go test -run "^$" -fuzz="${target}" -fuzztime={{.FUZZ_TIME}} .; then
echo "PASS: ${target}"
else
echo "FAIL: ${target}"
exit_code=1
fi
done
exit $exit_code
36 changes: 28 additions & 8 deletions arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
args = make([]string, 0)
advArgs = make(map[string]string)

macroLoop:
for {
ch, err := sr.read()
if err != nil {
Expand Down Expand Up @@ -111,7 +112,8 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
break
}

if ch == SymInputMacroExtStart {
switch ch {
case SymInputMacroExtStart:
extName := string(ch)
var extBuilder strings.Builder
for {
Expand All @@ -131,7 +133,14 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
extName += extBuilder.String()
args = append(args, extName)
continue
} else if ch == SymAdvArgStart {
case SymExpressionStart:
exprValue, exprErr := sr.parseExpression()
if exprErr != nil {
return args, advArgs, exprErr
}
args = append(args, exprValue)
continue
case SymAdvArgStart:
newAdvArgs, buf, err := sr.parseAdvArgs()
if errors.Is(err, ErrInvalidAdvArgName) {
// if an adv arg name is invalid, fallback on treating it
Expand All @@ -147,10 +156,10 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
advArgs = newAdvArgs

// advanced args are always the last part of a command
break
break macroLoop
default:
args = append(args, string(ch))
}

args = append(args, string(ch))
}

return args, advArgs, nil
Expand Down Expand Up @@ -269,6 +278,9 @@ func (sr *ScriptReader) parseArgs(
advArgs = make(map[string]string)
currentArg := prefix
argStart := sr.pos
// tracks whether content was explicitly written, distinguishing
// "**cmd:" (no content, no arg) from "**cmd:''" (explicit empty arg)
argWritten := prefix != ""

argsLoop:
for {
Expand All @@ -286,13 +298,15 @@ argsLoop:
return args, advArgs, quotedErr
}
currentArg = quotedArg
argWritten = true
continue argsLoop
case argStart == sr.pos-1 && ch == SymJSONStart:
jsonArg, jsonErr := sr.parseJSONArg()
if jsonErr != nil {
return args, advArgs, jsonErr
}
currentArg = jsonArg
argWritten = true
continue argsLoop
case ch == SymEscapeSeq:
// escaping next character
Expand All @@ -301,9 +315,11 @@ argsLoop:
return args, advArgs, escapeErr
} else if next == "" {
currentArg += string(SymEscapeSeq)
argWritten = true
continue argsLoop
}
currentArg += next
argWritten = true
continue argsLoop
}

Expand All @@ -321,6 +337,7 @@ argsLoop:
args = append(args, currentArg)
currentArg = ""
argStart = sr.pos
argWritten = false
continue argsLoop
case ch == SymAdvArgStart:
newAdvArgs, buf, err := sr.parseAdvArgs()
Expand All @@ -344,18 +361,21 @@ argsLoop:
return args, advArgs, err
}
currentArg += exprValue
argWritten = true
continue argsLoop
default:
currentArg += string(ch)
if !isWhitespace(ch) {
argWritten = true
}
continue argsLoop
}
}

currentArg = strings.TrimSpace(currentArg)
if !onlyAdvArgs {
// if a cmd was called with ":" it will always have at least 1 blank arg
if !onlyAdvArgs && (currentArg != "" || argWritten) {
args = append(args, currentArg)
} else if currentArg != "" {
} else if onlyAdvArgs && currentArg != "" {
// fallback content from invalid adv args should still be preserved
args = append(args, currentArg)
}
Expand Down
8 changes: 6 additions & 2 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ commandLoop:
break commandLoop
}

cmd.Name = normalizeCmdName(cmd.Name)

onlyAdvArgs := false
if ch == SymAdvArgStart {
// roll it back to trigger adv arg parsing in parseArgs
Expand Down Expand Up @@ -180,7 +182,7 @@ commandLoop:
return cmd, string(buf), ErrEmptyCmdName
}

cmd.Name = strings.ToLower(cmd.Name)
cmd.Name = normalizeCmdName(cmd.Name)

return cmd, string(buf), nil
}
Expand All @@ -201,7 +203,9 @@ func (sr *ScriptReader) ParseScript() (Script, error) {
}
cmd := Command{
Name: ZapScriptCmdLaunch,
Args: args,
}
if len(args) > 0 {
cmd.Args = args
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if len(advArgs) > 0 {
cmd.AdvArgs = NewAdvArgs(advArgs)
Expand Down
80 changes: 80 additions & 0 deletions parser_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,86 @@ func TestParseInputMacroArgs(t *testing.T) {
},
},
},
// Expression handling in input macros
{
name: "input with expression",
input: `**input.keyboard:[[var]]`,
want: zapscript.Script{
Cmds: []zapscript.Command{
{Name: "input.keyboard", Args: []string{zapscript.TokExpStart + "var" + zapscript.TokExprEnd}},
},
},
},
{
name: "input with expression between chars",
input: `**input.keyboard:a[[var]]b`,
want: zapscript.Script{
Cmds: []zapscript.Command{
{Name: "input.keyboard", Args: []string{
"a", zapscript.TokExpStart + "var" + zapscript.TokExprEnd, "b",
}},
},
},
},
{
name: "input with single bracket not expression",
input: `**input.keyboard:a[b`,
want: zapscript.Script{
Cmds: []zapscript.Command{
{Name: "input.keyboard", Args: []string{"a", "[", "b"}},
},
},
},
{
name: "input with multiple expressions",
input: `**input.keyboard:[[a]][[b]]`,
want: zapscript.Script{
Cmds: []zapscript.Command{
{Name: "input.keyboard", Args: []string{
zapscript.TokExpStart + "a" + zapscript.TokExprEnd,
zapscript.TokExpStart + "b" + zapscript.TokExprEnd,
}},
},
},
},
{
name: "input with expression and advanced args",
input: `**input.keyboard:[[var]]?delay=100`,
want: zapscript.Script{
Cmds: []zapscript.Command{
{
Name: "input.keyboard",
Args: []string{zapscript.TokExpStart + "var" + zapscript.TokExprEnd},
AdvArgs: zapscript.NewAdvArgs(map[string]string{"delay": "100"}),
},
},
},
},
{
name: "input with escape sequence and expression",
input: `**input.keyboard:\n[[var]]`,
want: zapscript.Script{
Cmds: []zapscript.Command{
{Name: "input.keyboard", Args: []string{
"n", zapscript.TokExpStart + "var" + zapscript.TokExprEnd,
}},
},
},
},
{
name: "input with unmatched expression",
input: `**input.keyboard:[[var`,
wantErr: zapscript.ErrUnmatchedExpression,
},
{
name: "input gamepad with expression",
input: `**input.gamepad:[[var]]`,
want: zapscript.Script{
Cmds: []zapscript.Command{
{Name: "input.gamepad", Args: []string{zapscript.TokExpStart + "var" + zapscript.TokExprEnd}},
},
},
},
}

for _, tt := range tests {
Expand Down
Loading
Loading