From ee760ca2f1a8fff10f6dba91c249e3e01ee136b7 Mon Sep 17 00:00:00 2001 From: cabbage Date: Sun, 6 Sep 2026 06:02:08 +0000 Subject: [PATCH] feat: quote Windows supervisor arguments --- .../client/supervisor/windows/commandline.go | 54 +++++++++++++++++++ .../supervisor/windows/commandline_test.go | 42 +++++++++++++++ test/coverage.toml | 7 +++ 3 files changed, 103 insertions(+) create mode 100644 internal/client/supervisor/windows/commandline.go create mode 100644 internal/client/supervisor/windows/commandline_test.go diff --git a/internal/client/supervisor/windows/commandline.go b/internal/client/supervisor/windows/commandline.go new file mode 100644 index 0000000..e1fe800 --- /dev/null +++ b/internal/client/supervisor/windows/commandline.go @@ -0,0 +1,54 @@ +package windows + +import ( + "errors" + "strings" + "unicode/utf8" +) + +var ErrInvalidCommandLineArgument = errors.New("Windows command-line argument contains NUL or invalid UTF-8") + +// BuildCommandLine constructs the lpCommandLine string for an explicit +// lpApplicationName. It implements the CommandLineToArgvW-compatible escaping +// convention: quotes are escaped by a preceding run of backslashes and trailing +// backslashes inside a quoted argument are doubled. +func BuildCommandLine(arguments []string) (string, error) { + if len(arguments) == 0 { + return "", nil + } + quoted := make([]string, len(arguments)) + for index, argument := range arguments { + if strings.IndexByte(argument, 0) >= 0 || !utf8.ValidString(argument) { + return "", ErrInvalidCommandLineArgument + } + quoted[index] = quoteArgument(argument) + } + return strings.Join(quoted, " "), nil +} + +func quoteArgument(argument string) string { + if argument != "" && !strings.ContainsAny(argument, " \t\n\v\"") { + return argument + } + var output strings.Builder + output.Grow(len(argument) + 2) + output.WriteByte('"') + backslashes := 0 + for _, character := range argument { + switch character { + case '\\': + backslashes++ + case '"': + output.WriteString(strings.Repeat("\\", backslashes*2+1)) + output.WriteRune(character) + backslashes = 0 + default: + output.WriteString(strings.Repeat("\\", backslashes)) + output.WriteRune(character) + backslashes = 0 + } + } + output.WriteString(strings.Repeat("\\", backslashes*2)) + output.WriteByte('"') + return output.String() +} diff --git a/internal/client/supervisor/windows/commandline_test.go b/internal/client/supervisor/windows/commandline_test.go new file mode 100644 index 0000000..556a0fd --- /dev/null +++ b/internal/client/supervisor/windows/commandline_test.go @@ -0,0 +1,42 @@ +package windows + +import ( + "errors" + "testing" +) + +func TestBuildCommandLine_HP_CMD_01(t *testing.T) { + t.Parallel() + cases := []struct { + name string + arguments []string + want string + }{ + {"empty vector", nil, ""}, + {"ordinary", []string{"-File", `C:\ProgramData\RVBox\work\run.ps1`}, `-File C:\ProgramData\RVBox\work\run.ps1`}, + {"empty argument", []string{"-Command", ""}, `-Command ""`}, + {"spaces", []string{"-File", `C:\path with spaces\run.ps1`}, `-File "C:\path with spaces\run.ps1"`}, + {"embedded quote", []string{`say "hello"`}, `"say \"hello\""`}, + {"trailing slash", []string{`C:\path with spaces\`}, `"C:\path with spaces\\"`}, + {"unicode", []string{"-File", `C:\工作\脚本.ps1`}, `-File C:\工作\脚本.ps1`}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got, err := BuildCommandLine(test.arguments) + if err != nil || got != test.want { + t.Fatalf("BuildCommandLine(%q) = (%q, %v), want %q", test.arguments, got, err, test.want) + } + }) + } +} + +func TestBuildCommandLineRejectsNUL_BH_CMD_01(t *testing.T) { + t.Parallel() + if _, err := BuildCommandLine([]string{"safe", "bad\x00argument"}); !errors.Is(err, ErrInvalidCommandLineArgument) { + t.Fatalf("NUL argument error = %v", err) + } + if _, err := BuildCommandLine([]string{string([]byte{0xff})}); !errors.Is(err, ErrInvalidCommandLineArgument) { + t.Fatalf("invalid UTF-8 argument error = %v", err) + } +} diff --git a/test/coverage.toml b/test/coverage.toml index 3145b6e..c629899 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -61,8 +61,15 @@ tests = [ "internal/domain/lifecycle_test.go:TestLifecycleTransitionMatrix_HP_CMD_01", "internal/domain/lifecycle_test.go:TestTerminalPredicateExhaustive_HP_CMD_01", "internal/domain/revision_test.go:TestRevisionComparisonAndAdvance_HP_CMD_01", + "internal/client/supervisor/windows/commandline_test.go:TestBuildCommandLine_HP_CMD_01", ] +[[requirements]] +id = "BH-CMD-01" +layer = "unit" +status = "implemented" +tests = ["internal/client/supervisor/windows/commandline_test.go:TestBuildCommandLineRejectsNUL_BH_CMD_01"] + [[requirements]] id = "HP-LAUNCH-01" layer = "unit"