Browse Source

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
pull/7183/merge
Chris Lu 1 day ago
committed by GitHub
parent
commit
49c66bbb2e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 79
      weed/shell/shell_liner.go
  2. 105
      weed/shell/shell_liner_test.go

79
weed/shell/shell_liner.go

@ -7,7 +7,6 @@ import (
"math/rand/v2" "math/rand/v2"
"os" "os"
"path" "path"
"regexp"
"slices" "slices"
"strings" "strings"
@ -43,8 +42,6 @@ func RunShell(options ShellOptions) {
defer saveHistory() defer saveHistory()
reg, _ := regexp.Compile(`'.*?'|".*?"|\S+`)
commandEnv := NewCommandEnv(&options) commandEnv := NewCommandEnv(&options)
ctx := context.Background() ctx := context.Background()
@ -89,25 +86,21 @@ func RunShell(options ShellOptions) {
} }
for _, c := range util.StringSplit(cmd, ";") { for _, c := range util.StringSplit(cmd, ";") {
if processEachCmd(reg, c, commandEnv) {
if processEachCmd(c, commandEnv) {
return 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 { if len(cmds) == 0 {
return false return false
} else { } 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] cmd := cmds[0]
if cmd == "help" || cmd == "?" { if cmd == "help" || cmd == "?" {
@ -133,6 +126,70 @@ func processEachCmd(reg *regexp.Regexp, cmd string, commandEnv *CommandEnv) bool
return false 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() { func printGenericHelp() {
msg := msg :=
`Type: "help <command>" for help on <command>. Most commands support "<command> -h" also for options. `Type: "help <command>" for help on <command>. Most commands support "<command> -h" also for options.

105
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)
}
}
Loading…
Cancel
Save