From 3ecf9b35f32ab4f59626018385b6231ebfe67b4d Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sun, 7 Sep 2025 15:31:17 +0800 Subject: [PATCH] refine: safe shell cmd line parse --- internal/build/run.go | 12 ++- internal/flash/flash.go | 8 +- internal/shellparse/shellparse.go | 86 ++++++++++++++++++ internal/shellparse/shellparse_test.go | 115 +++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 internal/shellparse/shellparse.go create mode 100644 internal/shellparse/shellparse_test.go diff --git a/internal/build/run.go b/internal/build/run.go index 824af3fc..a37a894c 100644 --- a/internal/build/run.go +++ b/internal/build/run.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/goplus/llgo/internal/mockable" + "github.com/goplus/llgo/internal/shellparse" ) func runNative(ctx *context, app, pkgDir, pkgName string, conf *Config, mode Mode) error { @@ -123,10 +124,13 @@ func runEmuCmd(envMap map[string]string, emulatorTemplate string, runArgs []stri fmt.Fprintf(os.Stderr, "Running in emulator: %s\n", emulatorCmd) } - // Parse command and arguments - cmdParts := strings.Fields(emulatorCmd) + // Parse command and arguments safely handling quoted strings + cmdParts, err := shellparse.Parse(emulatorCmd) + if err != nil { + return fmt.Errorf("failed to parse emulator command: %w", err) + } if len(cmdParts) == 0 { - panic(fmt.Errorf("empty emulator command")) + return fmt.Errorf("empty emulator command") } // Add run arguments to the end @@ -137,7 +141,7 @@ func runEmuCmd(envMap map[string]string, emulatorTemplate string, runArgs []stri cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - err := cmd.Run() + err = cmd.Run() if err != nil { return err } diff --git a/internal/flash/flash.go b/internal/flash/flash.go index d5ac65b0..281686bd 100644 --- a/internal/flash/flash.go +++ b/internal/flash/flash.go @@ -12,6 +12,7 @@ import ( "time" "github.com/goplus/llgo/internal/env" + "github.com/goplus/llgo/internal/shellparse" "go.bug.st/serial" "go.bug.st/serial/enumerator" ) @@ -254,8 +255,11 @@ func flashCommand(flash Flash, envMap map[string]string, port string, verbose bo fmt.Fprintf(os.Stderr, "Flash command: %s\n", expandedCommand) } - // Split command into parts for exec - parts := strings.Fields(expandedCommand) + // Split command into parts for exec - safely handling quoted arguments + parts, err := shellparse.Parse(expandedCommand) + if err != nil { + return fmt.Errorf("failed to parse flash command: %w", err) + } if len(parts) == 0 { return fmt.Errorf("empty flash command after expansion") } diff --git a/internal/shellparse/shellparse.go b/internal/shellparse/shellparse.go new file mode 100644 index 00000000..ab13d9d3 --- /dev/null +++ b/internal/shellparse/shellparse.go @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package shellparse + +import ( + "fmt" + "strings" + "unicode" +) + +// Parse parses a shell command string into command and arguments, +// properly handling quoted arguments with spaces +func Parse(cmd string) ([]string, error) { + args := make([]string, 0) + var current strings.Builder + var inQuotes bool + var quoteChar rune + var hasContent bool // Track if we've seen content (including empty quotes) + + runes := []rune(cmd) + for i := 0; i < len(runes); i++ { + r := runes[i] + switch { + case !inQuotes && (r == '"' || r == '\''): + // Start of quoted string + inQuotes = true + quoteChar = r + hasContent = true // Empty quotes still count as content + case inQuotes && r == quoteChar: + // End of quoted string + inQuotes = false + quoteChar = 0 + case !inQuotes && unicode.IsSpace(r): + // Space outside quotes - end current argument + if hasContent { + args = append(args, current.String()) + current.Reset() + hasContent = false + } + case inQuotes && r == '\\' && i+1 < len(runes): + // Handle escape sequences in quotes + if quoteChar == '"' { + next := runes[i+1] + if next == quoteChar || next == '\\' { + current.WriteRune(next) + i++ // Skip the next rune + } else { + current.WriteRune(r) + } + } else { + // In single quotes, backslash is a literal character. + current.WriteRune(r) + } + default: + // Regular character + current.WriteRune(r) + hasContent = true + } + } + + // Handle unterminated quotes + if inQuotes { + return nil, fmt.Errorf("unterminated quote in command: %s", cmd) + } + + // Add final argument if any + if hasContent { + args = append(args, current.String()) + } + + return args, nil +} diff --git a/internal/shellparse/shellparse_test.go b/internal/shellparse/shellparse_test.go new file mode 100644 index 00000000..cb195532 --- /dev/null +++ b/internal/shellparse/shellparse_test.go @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package shellparse + +import ( + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + cmd string + want []string + wantErr bool + }{ + { + name: "simple command", + cmd: "echo hello", + want: []string{"echo", "hello"}, + }, + { + name: "command with quoted argument", + cmd: `echo "hello world"`, + want: []string{"echo", "hello world"}, + }, + { + name: "command with single quoted argument", + cmd: `echo 'hello world'`, + want: []string{"echo", "hello world"}, + }, + { + name: "command with multiple quoted arguments", + cmd: `cp "file with spaces.txt" "another file.txt"`, + want: []string{"cp", "file with spaces.txt", "another file.txt"}, + }, + { + name: "command with escaped quotes", + cmd: `echo "He said \"hello\""`, + want: []string{"echo", `He said "hello"`}, + }, + { + name: "command with escaped backslash", + cmd: `echo "path\\to\\file"`, + want: []string{"echo", `path\to\file`}, + }, + { + name: "mixed quotes", + cmd: `echo "double quote" 'single quote' normal`, + want: []string{"echo", "double quote", "single quote", "normal"}, + }, + { + name: "empty arguments", + cmd: `echo "" ''`, + want: []string{"echo", "", ""}, + }, + { + name: "multiple spaces", + cmd: "echo hello world", + want: []string{"echo", "hello", "world"}, + }, + { + name: "emulator command example", + cmd: `qemu-system-xtensa -machine esp32 -kernel "/path/with spaces/firmware.bin"`, + want: []string{"qemu-system-xtensa", "-machine", "esp32", "-kernel", "/path/with spaces/firmware.bin"}, + }, + { + name: "unterminated double quote", + cmd: `echo "hello`, + wantErr: true, + }, + { + name: "unterminated single quote", + cmd: `echo 'hello`, + wantErr: true, + }, + { + name: "empty command", + cmd: "", + want: []string{}, + }, + { + name: "only spaces", + cmd: " ", + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.cmd) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +}