From 49c66bbb2e7ddd5f3025767ebf23b958f12714ec Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 29 Jan 2026 19:06:17 -0800 Subject: [PATCH] shell: allow spaces in arguments via quoting (#8157) (#8165) * shell: allow spaces in arguments via quoting (#8157) - updated argument splitting regex to handle quoted segments - added robust quote stripping to remove matching quotes from flags - added unit tests for regex splitting and flag parsing * shell: use robust state machine parser for command line arguments - replaced regex-based splitter with splitCommandLine state machine - added escape character support in splitCommandLine and stripQuotes - updated unit tests to include escaped quotes and single-quote literals - addressed feedback regarding escaped quotes handling (#8157) * shell: detect unbalanced quotes in stripQuotes - modified stripQuotes to return the original string if quotes are unbalanced - added test cases for unbalanced quotes in shell_liner_test.go * shell: refactor shared parsing logic into parseShellInput helper - unified splitting and unquoting logic into a single state machine - splitCommandLine now returns unquoted tokens directly - simplified processEachCmd by removing redundant unquoting loop - improved maintainability by eliminating code duplication * shell: detect trailing backslash in stripQuotes - updated parseShellInput to include escaped state in unbalanced flag - stripQuotes now returns original string if it ends with an unescaped backslash - added test case for trailing backslash in shell_liner_test.go --- weed/shell/shell_liner.go | 79 +++++++++++++++++++++---- weed/shell/shell_liner_test.go | 105 +++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 weed/shell/shell_liner_test.go diff --git a/weed/shell/shell_liner.go b/weed/shell/shell_liner.go index 220b04343..00831d42e 100644 --- a/weed/shell/shell_liner.go +++ b/weed/shell/shell_liner.go @@ -7,7 +7,6 @@ import ( "math/rand/v2" "os" "path" - "regexp" "slices" "strings" @@ -43,8 +42,6 @@ func RunShell(options ShellOptions) { defer saveHistory() - reg, _ := regexp.Compile(`'.*?'|".*?"|\S+`) - commandEnv := NewCommandEnv(&options) ctx := context.Background() @@ -89,25 +86,21 @@ func RunShell(options ShellOptions) { } for _, c := range util.StringSplit(cmd, ";") { - if processEachCmd(reg, c, commandEnv) { + if processEachCmd(c, commandEnv) { return } } } } -func processEachCmd(reg *regexp.Regexp, cmd string, commandEnv *CommandEnv) bool { - cmds := reg.FindAllString(cmd, -1) +func processEachCmd(cmd string, commandEnv *CommandEnv) bool { + cmds := splitCommandLine(cmd) if len(cmds) == 0 { return false } else { - args := make([]string, len(cmds[1:])) - - for i := range args { - args[i] = strings.Trim(string(cmds[1+i]), "\"'") - } + args := cmds[1:] cmd := cmds[0] if cmd == "help" || cmd == "?" { @@ -133,6 +126,70 @@ func processEachCmd(reg *regexp.Regexp, cmd string, commandEnv *CommandEnv) bool return false } +func stripQuotes(s string) string { + tokens, unbalanced := parseShellInput(s, false) + if unbalanced { + return s + } + if len(tokens) > 0 { + return tokens[0] + } + return "" +} + +func splitCommandLine(line string) []string { + tokens, _ := parseShellInput(line, true) + return tokens +} + +func parseShellInput(line string, split bool) (args []string, unbalanced bool) { + var current strings.Builder + inDoubleQuotes := false + inSingleQuotes := false + escaped := false + + for i := 0; i < len(line); i++ { + c := line[i] + + if escaped { + current.WriteByte(c) + escaped = false + continue + } + + if c == '\\' && !inSingleQuotes { + escaped = true + continue + } + + if c == '"' && !inSingleQuotes { + inDoubleQuotes = !inDoubleQuotes + continue + } + + if c == '\'' && !inDoubleQuotes { + inSingleQuotes = !inSingleQuotes + continue + } + + if split && (c == ' ' || c == '\t' || c == '\n' || c == '\r') && !inDoubleQuotes && !inSingleQuotes { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + continue + } + + current.WriteByte(c) + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args, inDoubleQuotes || inSingleQuotes || escaped +} + func printGenericHelp() { msg := `Type: "help " for help on . Most commands support " -h" also for options. diff --git a/weed/shell/shell_liner_test.go b/weed/shell/shell_liner_test.go new file mode 100644 index 000000000..bfdd2b378 --- /dev/null +++ b/weed/shell/shell_liner_test.go @@ -0,0 +1,105 @@ +package shell + +import ( + "flag" + "reflect" + "testing" +) + +func TestSplitCommandLine(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + { + input: `s3.configure -user=test`, + expected: []string{`s3.configure`, `-user=test`}, + }, + { + input: `s3.configure -user=Test_number_004 -account_display_name="Test number 004" -actions=write -apply`, + expected: []string{`s3.configure`, `-user=Test_number_004`, `-account_display_name=Test number 004`, `-actions=write`, `-apply`}, + }, + { + input: `s3.configure -user=Test_number_004 -account_display_name='Test number 004' -actions=write -apply`, + expected: []string{`s3.configure`, `-user=Test_number_004`, `-account_display_name=Test number 004`, `-actions=write`, `-apply`}, + }, + { + input: `s3.configure -flag="a b"c'd e'`, + expected: []string{`s3.configure`, `-flag=a bcd e`}, + }, + { + input: `s3.configure -name="a\"b"`, + expected: []string{`s3.configure`, `-name=a"b`}, + }, + { + input: `s3.configure -path='a\ b'`, + expected: []string{`s3.configure`, `-path=a\ b`}, + }, + } + + for _, tt := range tests { + got := splitCommandLine(tt.input) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("input: %s\ngot: %v\nwant: %v", tt.input, got, tt.expected) + } + } +} + +func TestStripQuotes(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {input: `"Test number 004"`, expected: `Test number 004`}, + {input: `'Test number 004'`, expected: `Test number 004`}, + {input: `-account_display_name="Test number 004"`, expected: `-account_display_name=Test number 004`}, + {input: `-flag="a"b'c'`, expected: `-flag=abc`}, + {input: `-name="a\"b"`, expected: `-name=a"b`}, + {input: `-path='a\ b'`, expected: `-path=a\ b`}, + {input: `"unbalanced`, expected: `"unbalanced`}, + {input: `'unbalanced`, expected: `'unbalanced`}, + {input: `-name="a\"b`, expected: `-name="a\"b`}, + {input: `trailing\`, expected: `trailing\`}, + } + + for _, tt := range tests { + got := stripQuotes(tt.input) + if got != tt.expected { + t.Errorf("input: %s, got: %s, want: %s", tt.input, got, tt.expected) + } + } +} + +func TestFlagParsing(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + displayName := fs.String("account_display_name", "", "display name") + + rawArg := `-account_display_name="Test number 004"` + args := []string{stripQuotes(rawArg)} + err := fs.Parse(args) + if err != nil { + t.Fatal(err) + } + + expected := "Test number 004" + if *displayName != expected { + t.Errorf("got: [%s], want: [%s]", *displayName, expected) + } +} + +func TestEscapedFlagParsing(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + name := fs.String("name", "", "name") + + rawArg := `-name="a\"b"` + args := []string{stripQuotes(rawArg)} + err := fs.Parse(args) + if err != nil { + t.Fatal(err) + } + + expected := `a"b` + if *name != expected { + t.Errorf("got: [%s], want: [%s]", *name, expected) + } +}