Repository: twpayne/chezmoi
Pull request #502 (2019-12-02 13:27:03)

Use more robust shell quoting algorithm


Patch:
diff --git a/go.mod b/go.mod
index 0fcdea3f3..06794c7fc 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,6 @@ require (
 	github.com/Masterminds/goutils v1.1.0 // indirect
 	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/Masterminds/sprig v2.22.0+incompatible
-	github.com/alessio/shellescape v0.0.0-20190409004728-b115ca0f9053
 	github.com/coreos/go-semver v0.3.0
 	github.com/gobuffalo/packr/v2 v2.7.1
 	github.com/golang/protobuf v1.3.2 // indirect
diff --git a/go.sum b/go.sum
index 6ff2ad294..227c5a74d 100644
--- a/go.sum
+++ b/go.sum
@@ -13,8 +13,6 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alessio/shellescape v0.0.0-20190409004728-b115ca0f9053 h1:H/GMMKYPkEIC3DF/JWQz8Pdd+Feifov2EIgGfNpeogI=
-github.com/alessio/shellescape v0.0.0-20190409004728-b115ca0f9053/go.mod h1:xW8sBma2LE3QxFSzCnH9qe6gAE2yO9GvQaWwX89HxbE=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
diff --git a/internal/chezmoi/maybeshellquote.go b/internal/chezmoi/maybeshellquote.go
new file mode 100644
index 000000000..7ba63fa91
--- /dev/null
+++ b/internal/chezmoi/maybeshellquote.go
@@ -0,0 +1,51 @@
+package chezmoi
+
+import (
+	"regexp"
+)
+
+var needShellQuoteRegexp = regexp.MustCompile(`[^+\-./0-9A-Z_a-z]`)
+
+const (
+	backslash   = '\\'
+	singleQuote = '\''
+)
+
+// maybeShellQuote returns s quoted as a shell argument, if necessary.
+func maybeShellQuote(s string) string {
+	switch {
+	case s == "":
+		return "''"
+	case needShellQuoteRegexp.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, backslash, 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
+	}
+}
diff --git a/internal/chezmoi/verbosemutator.go b/internal/chezmoi/verbosemutator.go
index 3e9172e0a..543a1c1bd 100644
--- a/internal/chezmoi/verbosemutator.go
+++ b/internal/chezmoi/verbosemutator.go
@@ -12,7 +12,6 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/alessio/shellescape"
 	"github.com/pkg/diff"
 )
 
@@ -35,7 +34,7 @@ func NewVerboseMutator(w io.Writer, m Mutator, colored bool) *VerboseMutator {
 
 // Chmod implements Mutator.Chmod.
 func (m *VerboseMutator) Chmod(name string, mode os.FileMode) error {
-	action := fmt.Sprintf("chmod %o %s", mode, shellescape.Quote(name))
+	action := fmt.Sprintf("chmod %o %s", mode, maybeShellQuote(name))
 	err := m.m.Chmod(name, mode)
 	if err == nil {
 		_, _ = fmt.Fprintln(m.w, action)
@@ -59,7 +58,7 @@ func (m *VerboseMutator) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) {
 
 // Mkdir implements Mutator.Mkdir.
 func (m *VerboseMutator) Mkdir(name string, perm os.FileMode) error {
-	action := fmt.Sprintf("mkdir -m %o %s", perm, shellescape.Quote(name))
+	action := fmt.Sprintf("mkdir -m %o %s", perm, maybeShellQuote(name))
 	err := m.m.Mkdir(name, perm)
 	if err == nil {
 		_, _ = fmt.Fprintln(m.w, action)
@@ -71,7 +70,7 @@ func (m *VerboseMutator) Mkdir(name string, perm os.FileMode) error {
 
 // RemoveAll implements Mutator.RemoveAll.
 func (m *VerboseMutator) RemoveAll(name string) error {
-	action := fmt.Sprintf("rm -rf %s", shellescape.Quote(name))
+	action := fmt.Sprintf("rm -rf %s", maybeShellQuote(name))
 	err := m.m.RemoveAll(name)
 	if err == nil {
 		_, _ = fmt.Fprintln(m.w, action)
@@ -83,7 +82,7 @@ func (m *VerboseMutator) RemoveAll(name string) error {
 
 // Rename implements Mutator.Rename.
 func (m *VerboseMutator) Rename(oldpath, newpath string) error {
-	action := fmt.Sprintf("mv %s %s", shellescape.Quote(oldpath), shellescape.Quote(newpath))
+	action := fmt.Sprintf("mv %s %s", maybeShellQuote(oldpath), maybeShellQuote(newpath))
 	err := m.m.Rename(oldpath, newpath)
 	if err == nil {
 		_, _ = fmt.Fprintln(m.w, action)
@@ -112,7 +111,7 @@ func (m *VerboseMutator) Stat(name string) (os.FileInfo, error) {
 
 // WriteFile implements Mutator.WriteFile.
 func (m *VerboseMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
-	action := fmt.Sprintf("install -m %o /dev/null %s", perm, shellescape.Quote(name))
+	action := fmt.Sprintf("install -m %o /dev/null %s", perm, maybeShellQuote(name))
 	err := m.m.WriteFile(name, data, perm, currData)
 	if err == nil {
 		_, _ = fmt.Fprintln(m.w, action)
@@ -148,7 +147,7 @@ func (m *VerboseMutator) WriteFile(name string, data []byte, perm os.FileMode, c
 
 // WriteSymlink implements Mutator.WriteSymlink.
 func (m *VerboseMutator) WriteSymlink(oldname, newname string) error {
-	action := fmt.Sprintf("ln -sf %s %s", shellescape.Quote(oldname), shellescape.Quote(newname))
+	action := fmt.Sprintf("ln -sf %s %s", maybeShellQuote(oldname), maybeShellQuote(newname))
 	err := m.m.WriteSymlink(oldname, newname)
 	if err == nil {
 		_, _ = fmt.Fprintln(m.w, action)
@@ -162,13 +161,13 @@ func (m *VerboseMutator) WriteSymlink(oldname, newname string) error {
 func cmdString(cmd *exec.Cmd) string {
 	components := append([]string{cmd.Path}, cmd.Args[1:]...)
 	for i, component := range components {
-		components[i] = shellescape.Quote(component)
+		components[i] = maybeShellQuote(component)
 	}
 	s := strings.Join(components, " ")
 	if cmd.Dir == "" {
 		return s
 	}
-	return fmt.Sprintf("( cd %s && %s )", shellescape.Quote(cmd.Dir), s)
+	return fmt.Sprintf("( cd %s && %s )", maybeShellQuote(cmd.Dir), s)
 }
 
 func isBinary(data []byte) bool {


Test patch:
diff --git a/internal/chezmoi/maybeshellquote_test.go b/internal/chezmoi/maybeshellquote_test.go
new file mode 100644
index 000000000..195ddc8e4
--- /dev/null
+++ b/internal/chezmoi/maybeshellquote_test.go
@@ -0,0 +1,24 @@
+package chezmoi
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMaybeShellQuote(t *testing.T) {
+	for s, expected := range map[string]string{
+		``:    `''`,
+		`'`:   `\'`,
+		`''`:  `\'\'`,
+		`'a'`: `\''a'\'`,
+		`\`:   `'\\'`,
+		`\a`:  `'\\a'`,
+		`$a`:  `'$a'`,
+		`a`:   `a`,
+		`a/b`: `a/b`,
+		`a b`: `'a b'`,
+	} {
+		assert.Equal(t, expected, maybeShellQuote(s), "quoting %q", s)
+	}
+}