Skip to content

Commit

Permalink
Allow escaping "$" in command arguments
Browse files Browse the repository at this point in the history
The `sh.Exec` family of functions used to use `os.Expand` to expand
environment variables in command arguments, but that function offers no
escape syntax -- no way to avoid expanding dollar signs that the caller
really wants to include on the command line. This commit implements a
new, mostly compatible `Expand` function to allow escaping the
environment-variable syntax, plus an accompanying `Escape` function to
producing escaped strings.

This technically breaks backward compatibility; any existing code that
has used the sequence "\\" or "\$" in its command arguments will need to
be updated. Although the compatibility argument prevents `os.Expand`
from changing, documentation for `sh.Exec` hasn't actually stated that
it follows all the same rules as `os.Expand`, so it's possible to view
this change as merely a bugfix, formalizing a new syntax that had not
previously been established.

Fixes magefile#434
  • Loading branch information
rkennedy committed May 1, 2024
1 parent 2385abb commit 58d81dd
Show file tree
Hide file tree
Showing 2 changed files with 83 additions and 4 deletions.
58 changes: 54 additions & 4 deletions sh/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log"
"os"
"os/exec"
"regexp"
"strings"

"github.com/magefile/mage/mg"
Expand Down Expand Up @@ -95,8 +96,9 @@ func OutputWith(env map[string]string, cmd string, args ...string) (string, erro
// the command failed with. Env is a list of environment variables to set when
// running the command, these override the current environment variables set
// (which are also passed to the command). cmd and args may include references
// to environment variables in $FOO format, in which case these will be
// expanded before the command is run.
// to environment variables in $FOO or ${FOO} format, in which case these will
// be expanded before the command is run; use a backslash before the dollar
// sign to avoid this behavior.
//
// Ran reports if the command ran (rather than was not found or not executable).
// Code reports the exit code the command returned if it ran. If err == nil, ran
Expand All @@ -109,9 +111,9 @@ func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...s
}
return os.Getenv(s)
}
cmd = os.Expand(cmd, expand)
cmd = Expand(cmd, expand)
for i := range args {
args[i] = os.Expand(args[i], expand)
args[i] = Expand(args[i], expand)
}
ran, code, err := run(env, stdout, stderr, cmd, args...)
if err == nil {
Expand Down Expand Up @@ -182,3 +184,51 @@ func ExitStatus(err error) int {
}
return 1
}

// Escape returns an escaped version of the argument such that when environment
// expansion occurs in the command-running functions of this module, the result
// will be the original argument.
func Escape(arg string) string {
arg = strings.Replace(arg, `\`, `\\`, -1)
arg = strings.Replace(arg, "$", `\$`, -1)
return arg
}

var varExpr = regexp.MustCompile(`\\.|\$(\w+|[-*#$@!?0-9])|\$\{(\w+|[-*#$@!?0-9])\}|\$\{\}?`)

// Expand searches the input for segments of the form $var or ${var} and
// replaces them with the value returned by the given callback function, such
// as `os.Getenv]. It works just like [os.Expand], except that a backslash
// preceeding the dollar sign will escape it, allowing a literal dollar sign to
// appear in the output. Escape a backslash with another backslash. A backslash
// followed by any other character is reserved for future use; it may be
// omitted from the output or replaced by some other value determined in the
// future.
func Expand(s string, mapping func(string) string) string {
return varExpr.ReplaceAllStringFunc(s, func(match string) string {
switch match[0] {
case '\\':
// Escaped backslash or dollar expands to itself.
// Escaped anything else is reserved and gets removed.
if match[1] == '\\' || match[1] == '$' {
return match[1:2]
}
case '$':
if match[1] != '{' {
// We got an ordinary word. Omit the dollar and
// do the replacement.
return mapping(match[1:])
}
if len(match) > 3 {
// Omit the leading "${" and trailing "}" to
// get the name.
return mapping(match[2:len(match)-1])
}
// We got either "${" or "${}". They're both syntax
// errors.
default:
// Should never get here.
}
return ""
})
}
29 changes: 29 additions & 0 deletions sh/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,34 @@ func TestAutoExpand(t *testing.T) {
if s != "baz" {
t.Fatalf(`Expected "baz" but got %q`, s)
}
}

func TestAutoExpandPrecedent(t *testing.T) {
// Environment variables passed to OutputWith should take precedence
// over any variables set in the actual environment.
if err := os.Setenv("MAGE_FOO", "wrong"); err != nil {
t.Fatal(err)
}
s, err := OutputWith(map[string]string{
"MAGE_FOO": "right",
}, "echo", "$MAGE_FOO")
if err != nil {
t.Fatal(err)
}
if s != "right" {
t.Fatalf(`Expected "right" but got %q`, s)
}
}

func TestEscapeExpand(t *testing.T) {
s, err := OutputWith(map[string]string{
"MAGE_BAR": "bar",
}, os.Args[0], "-printArgs", "foo${MAGE_BAR}baz", Escape("foo${MAGE_BAR}baz"), `foo\$${MAGE_BAR}\\baz`)
if err != nil {
t.Fatal(err)
}
expected := "[foobarbaz foo${MAGE_BAR}baz foo$bar\\baz]"
if s != expected {
t.Fatalf(`Expected %q but got %q`, expected, s)
}
}

0 comments on commit 58d81dd

Please sign in to comment.