From 740f35790033dbf038040555c3884013db8290e6 Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 10 Oct 2026 12:28:07 +0200 Subject: [PATCH] Upstream twpayne/chezmoi state of the touched files before 41a618c046 --- internal/cmd/shellquote.go | 67 +++++++++++++++++++++++++++++++++ internal/cmd/shellquote_test.go | 60 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 internal/cmd/shellquote.go create mode 100644 internal/cmd/shellquote_test.go diff --git a/internal/cmd/shellquote.go b/internal/cmd/shellquote.go new file mode 100644 index 0000000..95e3fd4 --- /dev/null +++ b/internal/cmd/shellquote.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "regexp" + "strings" +) + +// nonShellLiteralRx is a regular expression that matches anything that is not a +// shell literal. +var nonShellLiteralRx = regexp.MustCompile(`[^+\-./0-9=A-Z_a-z]`) + +// shellQuote returns s quoted as a shell argument, if necessary. +func shellQuote(s string) string { + const ( + backslash = '\\' + singleQuote = '\'' + ) + + switch { + case s == "": + return "''" + case nonShellLiteralRx.MatchString(s): + result := make([]byte, 0, 2+len(s)) + inSingleQuotes := false + for _, b := range []byte(s) { + switch b { + case backslash: + if !inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = true + } + result = append(result, backslash, backslash) + case singleQuote: + if inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = false + } + result = append(result, '\\', singleQuote) + default: + if !inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = true + } + result = append(result, b) + } + } + if inSingleQuotes { + result = append(result, singleQuote) + } + return string(result) + default: + return s + } +} + +// shellQuoteCommand returns a string containing command and args shell quoted. +func shellQuoteCommand(command string, args []string) string { + if len(args) == 0 { + return shellQuote(command) + } + elems := make([]string, 1+len(args)) + elems[0] = shellQuote(command) + for i, arg := range args { + elems[i+1] = shellQuote(arg) + } + return strings.Join(elems, " ") +} diff --git a/internal/cmd/shellquote_test.go b/internal/cmd/shellquote_test.go new file mode 100644 index 0000000..8586209 --- /dev/null +++ b/internal/cmd/shellquote_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "maps" + "slices" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestShellQuote(t *testing.T) { + tcs := map[string]string{ + ``: `''`, + `'`: `\'`, + `''`: `\'\'`, + `'a'`: `\''a'\'`, + `\`: `'\\'`, + `\a`: `'\\a'`, + `$a`: `'$a'`, + `a`: `a`, + `a/b`: `a/b`, + `a b`: `'a b'`, + `--arg`: `--arg`, + `--arg=value`: `--arg=value`, + `it's`: `'it'\''s'`, + `$(echo foo)`: `'$(echo foo)'`, + } + for _, s := range slices.Sorted(maps.Keys(tcs)) { + assert.Equal(t, tcs[s], shellQuote(s), "quoting %q", s) + } +} + +func TestShellQuoteCommand(t *testing.T) { + for _, tc := range []struct { + command string + args []string + expected string + }{ + { + command: "command", + expected: "command", + }, + { + command: "command with spaces", + expected: "'command with spaces'", + }, + { + command: "command", + args: []string{"arg1"}, + expected: "command arg1", + }, + { + command: "command", + args: []string{"arg1", "arg 2 with spaces"}, + expected: "command arg1 'arg 2 with spaces'", + }, + } { + assert.Equal(t, tc.expected, shellQuoteCommand(tc.command, tc.args)) + } +}