From 0acc34e2197fa73121265d6b89cae1ea71a0998d Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 14 Apr 2026 06:43:25 +0800 Subject: [PATCH 1/3] fix: add expression evaluation support to input macro commands Input macro commands (input.keyboard, input.gamepad) processed characters one at a time with no expression handling, causing [[expression]] syntax to be split into individual characters instead of being evaluated. Closes #2 --- arguments.go | 19 +++++++--- parser_coverage_test.go | 80 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/arguments.go b/arguments.go index 752d780..ee4f84d 100644 --- a/arguments.go +++ b/arguments.go @@ -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 { @@ -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 { @@ -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 @@ -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 diff --git a/parser_coverage_test.go b/parser_coverage_test.go index 5cbc014..8e9c4f3 100644 --- a/parser_coverage_test.go +++ b/parser_coverage_test.go @@ -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 { From 46beba783c251fec6c7adc33335e6cf06716ba2e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 14 Apr 2026 11:02:57 +0800 Subject: [PATCH 2/3] feat: add fuzz testing infrastructure and fix Command.String() round-trip bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FuzzParseTagFilters and FuzzCommandString fuzz targets. The round-trip fuzzer (parse → String() → reparse) found and fixed several serialization bugs in Command.String(): - Normalize command name before isInputMacroCmd check (mixed case used wrong parser) - Quote args containing *, [, #, {, ' in String() output - Escape [ inside quoted args to prevent expression parsing - Escape \, ?, [, {, | in input macro String() output - Quote explicit empty string args as "" to distinguish from no args - Skip empty arg when colon has no content (argWritten tracking) Add fuzz task to Taskfile.yml matching zaparoo-core's pattern with configurable FUZZ_TIME. Add dedicated nightly fuzz.yml workflow with crash artifact upload and automatic issue creation. Remove inline fuzz from CI (corpus regressions still run via go test). --- .github/workflows/ci.yml | 21 ---- .github/workflows/fuzz.yml | 75 +++++++++++ Taskfile.yml | 25 ++++ arguments.go | 17 ++- parser.go | 9 +- parser_fuzz_test.go | 119 ++++++++++++++++++ parser_mutation_test.go | 4 +- reader.go | 37 +++++- symbols.go | 9 +- .../fuzz/FuzzCommandString/01ca04e414360be4 | 2 + .../fuzz/FuzzCommandString/085789c0f347eb2d | 2 + .../fuzz/FuzzCommandString/1db92f3d0ef87c11 | 2 + .../fuzz/FuzzCommandString/665c2cce8688359c | 2 + .../fuzz/FuzzCommandString/718a940357f048d5 | 2 + .../fuzz/FuzzCommandString/98522f8ffcdcc781 | 2 + .../fuzz/FuzzCommandString/a50862c2ccfa2e7e | 2 + .../fuzz/FuzzCommandString/b79ed5e224f405a6 | 2 + .../fuzz/FuzzCommandString/b7a29d913cf174b2 | 2 + .../fuzz/FuzzCommandString/c9b801b582d4ff5d | 2 + .../fuzz/FuzzCommandString/e187a4af421c245b | 2 + 20 files changed, 305 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 testdata/fuzz/FuzzCommandString/01ca04e414360be4 create mode 100644 testdata/fuzz/FuzzCommandString/085789c0f347eb2d create mode 100644 testdata/fuzz/FuzzCommandString/1db92f3d0ef87c11 create mode 100644 testdata/fuzz/FuzzCommandString/665c2cce8688359c create mode 100644 testdata/fuzz/FuzzCommandString/718a940357f048d5 create mode 100644 testdata/fuzz/FuzzCommandString/98522f8ffcdcc781 create mode 100644 testdata/fuzz/FuzzCommandString/a50862c2ccfa2e7e create mode 100644 testdata/fuzz/FuzzCommandString/b79ed5e224f405a6 create mode 100644 testdata/fuzz/FuzzCommandString/b7a29d913cf174b2 create mode 100644 testdata/fuzz/FuzzCommandString/c9b801b582d4ff5d create mode 100644 testdata/fuzz/FuzzCommandString/e187a4af421c245b diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a74fca2..4f0e6d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..85bb1da --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -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 + )" diff --git a/Taskfile.yml b/Taskfile.yml index 03662c9..770a45f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 diff --git a/arguments.go b/arguments.go index ee4f84d..a08feba 100644 --- a/arguments.go +++ b/arguments.go @@ -278,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 { @@ -295,6 +298,7 @@ argsLoop: return args, advArgs, quotedErr } currentArg = quotedArg + argWritten = true continue argsLoop case argStart == sr.pos-1 && ch == SymJSONStart: jsonArg, jsonErr := sr.parseJSONArg() @@ -302,6 +306,7 @@ argsLoop: return args, advArgs, jsonErr } currentArg = jsonArg + argWritten = true continue argsLoop case ch == SymEscapeSeq: // escaping next character @@ -310,9 +315,11 @@ argsLoop: return args, advArgs, escapeErr } else if next == "" { currentArg += string(SymEscapeSeq) + argWritten = true continue argsLoop } currentArg += next + argWritten = true continue argsLoop } @@ -330,6 +337,7 @@ argsLoop: args = append(args, currentArg) currentArg = "" argStart = sr.pos + argWritten = false continue argsLoop case ch == SymAdvArgStart: newAdvArgs, buf, err := sr.parseAdvArgs() @@ -353,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) } diff --git a/parser.go b/parser.go index 5341850..01c8d4e 100644 --- a/parser.go +++ b/parser.go @@ -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 @@ -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 } @@ -201,7 +203,10 @@ func (sr *ScriptReader) ParseScript() (Script, error) { } cmd := Command{ Name: ZapScriptCmdLaunch, - Args: args, + } + // hasArgs filters out empty-only args from adv arg fallback + if hasArgs(args) { + cmd.Args = args } if len(advArgs) > 0 { cmd.AdvArgs = NewAdvArgs(advArgs) diff --git a/parser_fuzz_test.go b/parser_fuzz_test.go index d7c944b..e3afd66 100644 --- a/parser_fuzz_test.go +++ b/parser_fuzz_test.go @@ -17,6 +17,8 @@ package zapscript import ( "testing" + + "github.com/google/go-cmp/cmp" ) // FuzzParseScript tests that ParseScript never panics on arbitrary input. @@ -181,3 +183,120 @@ func FuzzEvalExpressions(f *testing.F) { _, _ = evalParser.EvalExpressions(env) }) } + +// FuzzParseTagFilters tests that ParseTagFilters never panics on arbitrary input. +// Tag filter parsing handles operator prefixes, type:value splitting, and normalization. +func FuzzParseTagFilters(f *testing.F) { + seeds := []string{ + // Valid filters + `region:usa`, + `+genre:rpg`, + `-unfinished:demo`, + `~lang:en`, + // Multiple filters + `region:usa,genre:rpg`, + `+genre:rpg,-unfinished:demo,~lang:en,~lang:es`, + // Edge cases + ``, + `,`, + `,,,`, + `+`, + `-`, + `~`, + `nocolon`, + `:`, + `:value`, + `type:`, + `::`, + // Whitespace + ` region : usa `, + ` , , `, + // Special characters + `type:value with spaces`, + `type:1.2.3`, + `type:日本語`, //nolint:gosmopolitan // Japanese test case + `type:émoji🎮`, + // Long input + `a:b,` + string(make([]byte, 500)), + } + + for _, seed := range seeds { + f.Add(seed) + } + + f.Fuzz(func(_ *testing.T, input string) { + // Should not panic + _, _ = ParseTagFilters(input) + }) +} + +// FuzzCommandString tests Command.String() round-trip: parse → string → reparse. +// If a command parses successfully and serializes, reparsing should produce +// an equivalent command structure. +func FuzzCommandString(f *testing.F) { + seeds := []string{ + // Basic commands + `**launch:game.rom`, + `**delay:1000`, + `**echo:hello`, + // Multiple args + `**cmd:arg1,arg2,arg3`, + // Advanced args + `**cmd:arg?key=value`, + `**cmd:arg?key=value&other=thing`, + // Quoted args + `**cmd:"quoted arg"`, + `**cmd:'single quotes'`, + // Input macro commands + `**input.keyboard:abc`, + `**input.gamepad:abxy`, + `**input.keyboard:a{enter}b`, + // No args + `**stop`, + // Expressions (these won't round-trip but shouldn't panic) + `**launch:[[game_path]]`, + // Escapes + `**cmd:arg^,with^,commas`, + // Edge cases + `**cmd:`, + `**cmd:a`, + } + + for _, seed := range seeds { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, input string) { + p := NewParser(input) + script, err := p.ParseScript() + if err != nil { + return + } + + for _, cmd := range script.Cmds { + str := cmd.String() + + // Reparse the serialized command + p2 := NewParser(str) + script2, err := p2.ParseScript() + if err != nil { + t.Fatalf("round-trip reparse failed: input=%q → string=%q → error=%v", input, str, err) + } + + if len(script2.Cmds) != 1 { + t.Fatalf("round-trip produced %d commands, want 1: input=%q → string=%q", len(script2.Cmds), input, str) + } + + got := script2.Cmds[0] + if diff := cmp.Diff(cmd.Name, got.Name); diff != "" { + t.Errorf("round-trip name mismatch (-want +got):\n%s\ninput=%q → string=%q", diff, input, str) + } + if diff := cmp.Diff(cmd.Args, got.Args); diff != "" { + t.Errorf("round-trip args mismatch (-want +got):\n%s\ninput=%q → string=%q", diff, input, str) + } + if diff := cmp.Diff(cmd.AdvArgs, got.AdvArgs, cmp.AllowUnexported(AdvArgs{})); diff != "" { + t.Errorf("round-trip advargs mismatch (-want +got):\n%s\ninput=%q → string=%q", diff, input, str) + } + } + }) +} diff --git a/parser_mutation_test.go b/parser_mutation_test.go index 12be627..db1bc8c 100644 --- a/parser_mutation_test.go +++ b/parser_mutation_test.go @@ -808,13 +808,13 @@ func TestParseArgsMutations(t *testing.T) { }, }, }, - // Empty arg + // Colon with no content produces no args { name: "empty arg from colon only", input: `**cmd:`, want: zapscript.Script{ Cmds: []zapscript.Command{ - {Name: "cmd", Args: []string{""}}, + {Name: "cmd"}, }, }, }, diff --git a/reader.go b/reader.go index 23d7304..bec743f 100644 --- a/reader.go +++ b/reader.go @@ -96,14 +96,24 @@ type Command struct { Args []string } +func hasArgs(args []string) bool { + for _, arg := range args { + if arg != "" { + return true + } + } + return false +} + // argNeedsQuoting returns true if the arg contains characters that require // double-quoting to be safely represented in ZapScript. func argNeedsQuoting(s string) bool { for _, ch := range s { switch ch { case SymArgSep, SymArgStart, SymAdvArgStart, SymAdvArgSep, - SymAdvArgEq, SymArgDoubleQuote, SymCmdSep, SymEscapeSeq, - '\n', '\r', '\t': + SymAdvArgEq, SymArgDoubleQuote, SymArgSingleQuote, SymCmdSep, + SymEscapeSeq, SymCmdStart, SymExpressionStart, SymTraitsStart, + SymJSONStart, '\n', '\r', '\t': return true } } @@ -132,6 +142,9 @@ func escapeArg(s string) string { case SymEscapeSeq: _, _ = b.WriteRune(SymEscapeSeq) _, _ = b.WriteRune(SymEscapeSeq) + case SymExpressionStart: + _, _ = b.WriteRune(SymEscapeSeq) + _, _ = b.WriteRune(SymExpressionStart) default: _, _ = b.WriteRune(ch) } @@ -154,14 +167,30 @@ func (c Command) String() string { if isInputMacroCmd(c.Name) { // Input macro commands concatenate args directly for _, arg := range c.Args { - _, _ = b.WriteString(arg) + if len(arg) > 1 && rune(arg[0]) == SymInputMacroExtStart && + rune(arg[len(arg)-1]) == SymInputMacroExtEnd { + _, _ = b.WriteString(arg) + } else { + for _, ch := range arg { + switch ch { + case SymInputMacroEscapeSeq: + _, _ = b.WriteRune(SymInputMacroEscapeSeq) + _, _ = b.WriteRune(ch) + case SymAdvArgStart, SymExpressionStart, SymInputMacroExtStart, SymCmdSep: + _, _ = b.WriteRune(SymInputMacroEscapeSeq) + _, _ = b.WriteRune(ch) + default: + _, _ = b.WriteRune(ch) + } + } + } } } else { for i, arg := range c.Args { if i > 0 { _, _ = b.WriteRune(SymArgSep) } - if argNeedsQuoting(arg) { + if arg == "" || argNeedsQuoting(arg) { _, _ = b.WriteString(escapeArg(arg)) } else { _, _ = b.WriteString(arg) diff --git a/symbols.go b/symbols.go index 57610d5..876dbe7 100644 --- a/symbols.go +++ b/symbols.go @@ -15,7 +15,10 @@ package zapscript -import "errors" +import ( + "errors" + "strings" +) var ( ErrUnexpectedEOF = errors.New("unexpected end of file") @@ -67,6 +70,10 @@ const ( var eof = rune(0) +func normalizeCmdName(name string) string { + return strings.ToLower(name) +} + func isCmdName(ch rune) bool { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '.' } diff --git a/testdata/fuzz/FuzzCommandString/01ca04e414360be4 b/testdata/fuzz/FuzzCommandString/01ca04e414360be4 new file mode 100644 index 0000000..a8920c5 --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/01ca04e414360be4 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**input.keYBoArd:000000") diff --git a/testdata/fuzz/FuzzCommandString/085789c0f347eb2d b/testdata/fuzz/FuzzCommandString/085789c0f347eb2d new file mode 100644 index 0000000..84ebf58 --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/085789c0f347eb2d @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**input.keYBoArd?{") diff --git a/testdata/fuzz/FuzzCommandString/1db92f3d0ef87c11 b/testdata/fuzz/FuzzCommandString/1db92f3d0ef87c11 new file mode 100644 index 0000000..c5001e1 --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/1db92f3d0ef87c11 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**[[0") diff --git a/testdata/fuzz/FuzzCommandString/665c2cce8688359c b/testdata/fuzz/FuzzCommandString/665c2cce8688359c new file mode 100644 index 0000000..818a5bf --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/665c2cce8688359c @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**input.keYBoArd:\\\\0") diff --git a/testdata/fuzz/FuzzCommandString/718a940357f048d5 b/testdata/fuzz/FuzzCommandString/718a940357f048d5 new file mode 100644 index 0000000..a232ce4 --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/718a940357f048d5 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**input.keYBoArd:?[") diff --git a/testdata/fuzz/FuzzCommandString/98522f8ffcdcc781 b/testdata/fuzz/FuzzCommandString/98522f8ffcdcc781 new file mode 100644 index 0000000..3b41157 --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/98522f8ffcdcc781 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**input.keYBoArd:{\\}") diff --git a/testdata/fuzz/FuzzCommandString/a50862c2ccfa2e7e b/testdata/fuzz/FuzzCommandString/a50862c2ccfa2e7e new file mode 100644 index 0000000..65ae9ba --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/a50862c2ccfa2e7e @@ -0,0 +1,2 @@ +go test fuzz v1 +string("\v{") diff --git a/testdata/fuzz/FuzzCommandString/b79ed5e224f405a6 b/testdata/fuzz/FuzzCommandString/b79ed5e224f405a6 new file mode 100644 index 0000000..d7a838e --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/b79ed5e224f405a6 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**0:''") diff --git a/testdata/fuzz/FuzzCommandString/b7a29d913cf174b2 b/testdata/fuzz/FuzzCommandString/b7a29d913cf174b2 new file mode 100644 index 0000000..f20c48b --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/b7a29d913cf174b2 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**[") diff --git a/testdata/fuzz/FuzzCommandString/c9b801b582d4ff5d b/testdata/fuzz/FuzzCommandString/c9b801b582d4ff5d new file mode 100644 index 0000000..0b13c0b --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/c9b801b582d4ff5d @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**000: '") diff --git a/testdata/fuzz/FuzzCommandString/e187a4af421c245b b/testdata/fuzz/FuzzCommandString/e187a4af421c245b new file mode 100644 index 0000000..ce6593a --- /dev/null +++ b/testdata/fuzz/FuzzCommandString/e187a4af421c245b @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**input.keYBoArd?\\") From db20c1e5d2acf1f97519abc870852ba054cf099e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 14 Apr 2026 11:24:40 +0800 Subject: [PATCH 3/3] fix: address PR review feedback - Add paired explicit-empty test cases (**cmd:"" and **cmd:'') next to the empty-colon test to document the argWritten behavior contract - Replace hasArgs with len(args) > 0 in parseAutoLaunchCmd to preserve explicit empty args from quoted input - Normalize command name in String() before isInputMacroCmd check for consistent serialization of programmatically-constructed commands - Remove unused hasArgs function --- parser.go | 3 +-- parser_mutation_test.go | 19 +++++++++++++++++++ reader.go | 11 +---------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/parser.go b/parser.go index 01c8d4e..4566da3 100644 --- a/parser.go +++ b/parser.go @@ -204,8 +204,7 @@ func (sr *ScriptReader) ParseScript() (Script, error) { cmd := Command{ Name: ZapScriptCmdLaunch, } - // hasArgs filters out empty-only args from adv arg fallback - if hasArgs(args) { + if len(args) > 0 { cmd.Args = args } if len(advArgs) > 0 { diff --git a/parser_mutation_test.go b/parser_mutation_test.go index db1bc8c..76a81d7 100644 --- a/parser_mutation_test.go +++ b/parser_mutation_test.go @@ -818,6 +818,25 @@ func TestParseArgsMutations(t *testing.T) { }, }, }, + // Explicit empty quoted args are preserved + { + name: "explicit empty double-quoted arg", + input: `**cmd:""`, + want: zapscript.Script{ + Cmds: []zapscript.Command{ + {Name: "cmd", Args: []string{""}}, + }, + }, + }, + { + name: "explicit empty single-quoted arg", + input: `**cmd:''`, + want: zapscript.Script{ + Cmds: []zapscript.Command{ + {Name: "cmd", Args: []string{""}}, + }, + }, + }, // Escape sequence before comma { name: "escaped comma in arg", diff --git a/reader.go b/reader.go index bec743f..b535686 100644 --- a/reader.go +++ b/reader.go @@ -96,15 +96,6 @@ type Command struct { Args []string } -func hasArgs(args []string) bool { - for _, arg := range args { - if arg != "" { - return true - } - } - return false -} - // argNeedsQuoting returns true if the arg contains characters that require // double-quoting to be safely represented in ZapScript. func argNeedsQuoting(s string) bool { @@ -164,7 +155,7 @@ func (c Command) String() string { if len(c.Args) > 0 { _, _ = b.WriteRune(SymArgStart) - if isInputMacroCmd(c.Name) { + if isInputMacroCmd(normalizeCmdName(c.Name)) { // Input macro commands concatenate args directly for _, arg := range c.Args { if len(arg) > 1 && rune(arg[0]) == SymInputMacroExtStart &&