main: Try to expand arguments starting with "@" from files on disk

This is a GCC-like behavior where we preproces the argument vector by
trying to treat any argument starting with "@" as a placeholder for
arguments loaded from a given filename.

As with GCC, if the remainder of the string cannot be used as a filename
to open and read, the original argument is retained literally. However,
if it _is_ possible to open and read a file of the given name then we
take that as sufficient evidence of the operator's intent to read from
that file and will fail with an error message if the file contents are not
acceptable syntax.

This uses the same upstream library as we've already been using for the
TF_CLI_ARGS... environment variables. Since main.go was quite overcrowded
with various different concerns, the handling of those environment
variables also moves into the new arguments.go file here, since the two
share an upstream dependency and both involve simplistic substitutions and
insertions into the argument vector. The observable behavior of the
environment variable handling should not be changed in any way by this
commit.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
Martin Atkins 2025-01-29 15:23:43 -08:00
parent 6614782e6d
commit 0e1fcb1bf7
26 changed files with 574 additions and 283 deletions

128
cmd/tofu/arguments.go Normal file
View File

@ -0,0 +1,128 @@
// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package main
import (
"fmt"
"log"
"os"
"strings"
shellwords "github.com/mattn/go-shellwords"
)
const (
// EnvCLI is the environment variable name to set additional CLI args.
EnvCLI = "TF_CLI_ARGS"
)
// expandFileBasedArgs searches the given args for elements whose first character is
// "@" (the "at" sign) and attempts to replace them with zero or more arguments
// parsed from a file named by the remaining characters in the string.
//
// If the remainder of the string cannot be treated as a filename for opening and
// reading then the argument is left unchanged, leaving it up to a subsequent
// codepath to decide how to treat it.
//
// The file contents are parsed using a Unix-shell-like tokenization scheme to
// produce a vector of additional arguments that will replace the original
// argument.
func expandFileBasedArgs(args []string) ([]string, error) {
// We assume that in the common case there will be no @-based arguments to
// expand and so we will wait to allocate a new slice until we know we
// need it. A nil "ret" at the end of this function therefore means that
// we should return args verbatim.
var ret []string
for i, arg := range args {
if !strings.HasPrefix(arg, "@") {
if ret != nil { // only if we've already started building a new arg vector on a previous iteration
ret = append(ret, arg)
}
continue
}
filename := arg[1:]
raw, err := os.ReadFile(filename)
if err != nil {
// We intentionally ignore errors here and just retain the
// original argument verbatim, assuming that the user intended
// to write a literal argument that starts with @.
if ret != nil { // only if we've already started building a new arg vector on a previous iteration
ret = append(ret, arg)
}
continue
}
extra, err := shellwords.Parse(string(raw))
if err != nil {
// In this case it seems more likely that the operator _was_ intending
// for this file to be treated as a set of additional arguments, but
// it contains some sort of syntax error like an unmatched opening
// quote. Therefore we'll return an error in this case to hopefully
// give the operator better feedback about the problem.
return args, fmt.Errorf("failed to expand %q argument: %w", arg, err)
}
// If we've got this far then we're definitely returning a different
// args vector than we were given, so we'll allocate it now if we
// didn't already.
if ret == nil {
// We know that we need at least enough space for all of the
// given args and the result of the one expansion we're currently
// working on, so we'll preallocate that but this might get
// reallocated by the appends below on a future iteration if
// there are multiple @-arguments.
// (-1 below because the "extra" elements are replacing our current "arg")
ret = make([]string, 0, len(args)+len(extra)-1)
ret = append(ret, args[:i]...) // start with any arguments that we already passed over
}
ret = append(ret, extra...)
}
if ret == nil {
return args, nil // We did not find any expandable arguments, so we'll return what we were given.
}
return ret, nil
}
func mergeEnvArgs(envName string, cmd string, args []string) ([]string, error) {
v := os.Getenv(envName)
if v == "" {
return args, nil
}
log.Printf("[INFO] %s value: %q", envName, v)
extra, err := shellwords.Parse(v)
if err != nil {
return nil, fmt.Errorf("error parsing extra CLI args from %s: %w", envName, err)
}
// Find the command to look for in the args. If there is a space,
// we need to find the last part.
search := cmd
if idx := strings.LastIndex(search, " "); idx >= 0 {
search = cmd[idx+1:]
}
// Find the index to place the flags. We put them exactly
// after the first non-flag arg.
idx := -1
for i, v := range args {
if v == search {
idx = i
break
}
}
// idx points to the exact arg that isn't a flag. We increment
// by one so that all the copying below expects idx to be the
// insertion point.
idx++
// Copy the args
newArgs := make([]string, len(args)+len(extra))
copy(newArgs, args[:idx])
copy(newArgs[idx:], extra)
copy(newArgs[len(extra)+idx:], args[idx:])
return newArgs, nil
}

404
cmd/tofu/arguments_test.go Normal file
View File

@ -0,0 +1,404 @@
// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package main
import (
"fmt"
"os"
"reflect"
"slices"
"testing"
"github.com/mitchellh/cli"
)
func TestExpandFileBasedArgs(t *testing.T) {
fixtureDir := "testdata/args-from-files"
tests := map[string]struct {
WantExpanded []string
WantError string
}{
"empty.txt": {
WantExpanded: []string{},
},
"escapes.txt": {
WantExpanded: []string{"can", "escape spaces", "and\nnewlines", "and", `"double`, `quotes"`, "and", "'single", "quotes'!"},
},
"unquoted.txt": {
WantExpanded: []string{"-foo", "bar", "baz"},
},
"multiline.txt": {
WantExpanded: []string{"bar", "baz", "beep", "boop"},
},
"backtick.txt": {
WantError: `failed to expand "@testdata/args-from-files/backtick.txt" argument: invalid command line string`,
},
"cmdsubst.txt": {
WantError: `failed to expand "@testdata/args-from-files/cmdsubst.txt" argument: invalid command line string`,
},
"doublequote.txt": {
WantExpanded: []string{"beep boop", "foo", "blah blah"},
},
"doublequote-envvar-like.txt": {
WantExpanded: []string{"no $INTERPOLATION inside double quotes"},
},
"doublequote-multiline.txt": {
WantExpanded: []string{"hello\nworld"},
},
"doublequote-spaces.txt": {
WantExpanded: []string{},
},
"doublequote-unclosed.txt": {
WantError: `failed to expand "@testdata/args-from-files/doublequote-unclosed.txt" argument: invalid command line string`,
},
"envvar-like.txt": {
WantExpanded: []string{"we", "$DONT", "expand", "things", "that", "look", "like", "${ENVVAR}interpolations"},
},
"parens.txt": {
// https://github.com/mattn/go-shellwords/issues/54
WantError: `failed to expand "@testdata/args-from-files/parens.txt" argument: invalid command line string`,
},
"parens-quoted.txt": {
WantExpanded: []string{"it's okay to have (parens) in double quotes", "and (also) in single quotes"},
},
"singlequote.txt": {
// This fails because the upstream library seems to mishandle double quotes inside single quotes
WantExpanded: []string{"-var=something=foo bar"},
},
"singlequote-multiline.txt": {
WantExpanded: []string{"hello\nworld"},
},
"singlequote-spaces.txt": {
WantExpanded: []string{},
},
"singlequote-unclosed.txt": {
WantError: `failed to expand "@testdata/args-from-files/singlequote-unclosed.txt" argument: invalid command line string`,
},
"nonexisting.txt": { // This intentionally refers to a filename that doesn't exist under "testdata/args-from-files"
// Apparent reference to nonexisting file is taken literally instead
WantExpanded: []string{"@testdata/args-from-files/nonexisting.txt"},
},
"unix-style-path.txt": {
WantExpanded: []string{"-var-file=../bar/baz.tfvars"},
},
"windows-style-path.txt": {
// The parsing library we use follows Unix-shell-style conventions and so backslashes
// are treated as escape characters unless in single quotes or escaped by doubling up.
WantExpanded: []string{
"-var-file=..barbaz.tfvars",
`-var-file=..\bar\baz.tfvars`,
`-var-file=..\bar\baz.tfvars`,
},
},
"metachars.txt": {
// This test fails because of https://github.com/mattn/go-shellwords/issues/57
// It currently seems to just halt parsing at the >, which is strange. I'd
// expect it to either take all of the metacharacters literally or return
// an error saying that this isn't valid syntax.
WantExpanded: []string{"not", "a", "shell,", "so", ">we", "take", "<redirection", "metacharacters", "|", "literally", "2>&but", "still", "accept", "them;", "blah"},
},
"metachars-singlequote.txt": {
WantExpanded: []string{"in quotes >we take <redirection metacharacters | literally 2>&and as a normal; part of the overall string"},
},
}
for basename, test := range tests {
t.Run(basename, func(t *testing.T) {
// NOTE: Intentionally not filepath.Join here because that would use backslashes
// on Windows but some of our WantExpanded entries want these strings to be
// taken literally and need them to be normal slashes.
filename := fixtureDir + "/" + basename
// We'll try a few different variations with the expansion argument in
// different positions with respect to other non-expanding arguments.
makeTest := func(transform func([]string) []string) func(t *testing.T) {
return func(t *testing.T) {
input := transform([]string{"@" + filename})
got, err := expandFileBasedArgs(input)
if test.WantError != "" {
if err == nil {
t.Errorf("unexpected success; want error: %s", test.WantError)
} else if gotMsg, wantMsg := err.Error(), test.WantError; gotMsg != wantMsg {
t.Errorf("wrong error\ngot: %s\nwant: %s", gotMsg, wantMsg)
}
return
}
want := make([]string, len(test.WantExpanded))
copy(want, test.WantExpanded) // copy so that the transform function can't inadvertenly modify the original backing array
want = transform(want)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if !slices.Equal(got, want) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want)
}
}
}
t.Run("alone", makeTest(func(s []string) []string {
return s // just the input alone, without any other arguments
}))
t.Run("before", makeTest(func(s []string) []string {
return append(s, "...")
}))
t.Run("after", makeTest(func(s []string) []string {
return append([]string{"..."}, s...)
}))
t.Run("between", makeTest(func(s []string) []string {
return append(append([]string{"..."}, s...), "...")
}))
t.Run("twice around", makeTest(func(s []string) []string {
return append(append(s, "..."), s...)
}))
})
}
// We'll also make sure we have a table entry for every file that's in
// the testdata directory, because it would be unfortunate if we had
// a test case in there that just got silently ignored.
allTestFixtures, err := os.ReadDir(fixtureDir)
if err != nil {
t.Fatalf("failed to enumerate all test fixture files: %s", err)
}
for _, entry := range allTestFixtures {
if _, ok := tests[entry.Name()]; !ok {
t.Errorf("no test case for fixture file %q", entry.Name())
}
}
}
func TestMain_cliArgsFromEnv(t *testing.T) {
// Set up the state. This test really messes with the environment and
// global state so we set things up to be restored.
// Restore original CLI args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test command and restore that
commands = make(map[string]cli.CommandFactory)
defer func() {
commands = nil
}()
testCommandName := "unit-test-cli-args"
testCommand := &testCommandCLI{}
commands[testCommandName] = func() (cli.Command, error) {
return testCommand, nil
}
cases := []struct {
Name string
Args []string
Value string
Expected []string
Err bool
}{
{
"no env",
[]string{testCommandName, "foo", "bar"},
"",
[]string{"foo", "bar"},
false,
},
{
"both env var and CLI",
[]string{testCommandName, "foo", "bar"},
"-foo baz",
[]string{"-foo", "baz", "foo", "bar"},
false,
},
{
"only env var",
[]string{testCommandName},
"-foo bar",
[]string{"-foo", "bar"},
false,
},
{
"cli string has blank values",
[]string{testCommandName, "bar", "", "baz"},
"-foo bar",
[]string{"-foo", "bar", "bar", "", "baz"},
false,
},
{
"cli string has blank values before the command",
[]string{"", testCommandName, "bar"},
"-foo bar",
[]string{"-foo", "bar", "bar"},
false,
},
{
// this should fail gracefully, this is just testing
// that we don't panic with our slice arithmetic
"no command",
[]string{},
"-foo bar",
nil,
true,
},
{
"single quoted strings",
[]string{testCommandName, "foo"},
"-foo 'bar baz'",
[]string{"-foo", "bar baz", "foo"},
false,
},
{
"double quoted strings",
[]string{testCommandName, "foo"},
`-foo "bar baz"`,
[]string{"-foo", "bar baz", "foo"},
false,
},
{
"double quoted single quoted strings",
[]string{testCommandName, "foo"},
`-foo "'bar baz'"`,
[]string{"-foo", "'bar baz'", "foo"},
false,
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) {
// Set the env var value
if tc.Value != "" {
t.Setenv(EnvCLI, tc.Value)
}
// Set up the args
args := make([]string, len(tc.Args)+1)
args[0] = oldArgs[0] // process name
copy(args[1:], tc.Args)
// Run it!
os.Args = args
testCommand.Args = nil
exit := realMain()
if (exit != 0) != tc.Err {
t.Fatalf("bad: %d", exit)
}
if tc.Err {
return
}
// Verify
if !reflect.DeepEqual(testCommand.Args, tc.Expected) {
t.Fatalf("expected args %#v but got %#v", tc.Expected, testCommand.Args)
}
})
}
}
// This test just has more options than the test above. Use this for
// more control over behavior at the expense of more complex test structures.
func TestMain_cliArgsFromEnvAdvanced(t *testing.T) {
// Restore original CLI args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test command and restore that
commands = make(map[string]cli.CommandFactory)
defer func() {
commands = nil
}()
cases := []struct {
Name string
Command string
EnvVar string
Args []string
Value string
Expected []string
Err bool
}{
{
"targeted to another command",
"command",
EnvCLI + "_foo",
[]string{"command", "foo", "bar"},
"-flag",
[]string{"foo", "bar"},
false,
},
{
"targeted to this command",
"command",
EnvCLI + "_command",
[]string{"command", "foo", "bar"},
"-flag",
[]string{"-flag", "foo", "bar"},
false,
},
{
"targeted to a command with a hyphen",
"command-name",
EnvCLI + "_command_name",
[]string{"command-name", "foo", "bar"},
"-flag",
[]string{"-flag", "foo", "bar"},
false,
},
{
"targeted to a command with a space",
"command name",
EnvCLI + "_command_name",
[]string{"command", "name", "foo", "bar"},
"-flag",
[]string{"-flag", "foo", "bar"},
false,
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) {
// Set up test command and restore that
testCommandName := tc.Command
testCommand := &testCommandCLI{}
defer func() { delete(commands, testCommandName) }()
commands[testCommandName] = func() (cli.Command, error) {
return testCommand, nil
}
// Set the env var value
if tc.Value != "" {
t.Setenv(tc.EnvVar, tc.Value)
}
// Set up the args
args := make([]string, len(tc.Args)+1)
args[0] = oldArgs[0] // process name
copy(args[1:], tc.Args)
// Run it!
os.Args = args
testCommand.Args = nil
exit := realMain()
if (exit != 0) != tc.Err {
t.Fatalf("unexpected exit status %d; want 0", exit)
}
if tc.Err {
return
}
// Verify
if !reflect.DeepEqual(testCommand.Args, tc.Expected) {
t.Fatalf("bad: %#v", testCommand.Args)
}
})
}
}

View File

@ -19,7 +19,6 @@ import (
"github.com/apparentlymart/go-shquot/shquot"
"github.com/hashicorp/go-plugin"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/mattn/go-shellwords"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/opentofu/opentofu/internal/addrs"
@ -36,9 +35,6 @@ import (
)
const (
// EnvCLI is the environment variable name to set additional CLI args.
EnvCLI = "TF_CLI_ARGS"
// The parent process will create a file to collect crash logs
envTmpLogPath = "TF_TEMP_LOG_PATH"
)
@ -226,6 +222,20 @@ func realMain() int {
return 1
}
// We expand the file-based args before potentially handing the -chdir option because it's
// our existing convention to do this sort of early file access relative to the "real"
// working directory (e.g. the CLI configuration handling and reattach provider handling
// above) and because it allows the -chdir option to potentially be part of one of the
// included files.
args, err = expandFileBasedArgs(args)
if err != nil {
// expandFileBasedArgs should return an error only when it has a strong signal that
// a particular argument was intended to be expanded from a file that exists but
// the contents of that file are somehow invalid.
Ui.Error(fmt.Sprintf("Invalid command line arguments: %s", err))
return 1
}
// The arguments can begin with a -chdir option to ask OpenTofu to switch
// to a different working directory for the rest of its work. If that
// option is present then extractChdirOption returns a trimmed args with that option removed.
@ -361,50 +371,6 @@ func realMain() int {
return exitCode
}
func mergeEnvArgs(envName string, cmd string, args []string) ([]string, error) {
v := os.Getenv(envName)
if v == "" {
return args, nil
}
log.Printf("[INFO] %s value: %q", envName, v)
extra, err := shellwords.Parse(v)
if err != nil {
return nil, fmt.Errorf(
"Error parsing extra CLI args from %s: %s",
envName, err)
}
// Find the command to look for in the args. If there is a space,
// we need to find the last part.
search := cmd
if idx := strings.LastIndex(search, " "); idx >= 0 {
search = cmd[idx+1:]
}
// Find the index to place the flags. We put them exactly
// after the first non-flag arg.
idx := -1
for i, v := range args {
if v == search {
idx = i
break
}
}
// idx points to the exact arg that isn't a flag. We increment
// by one so that all the copying below expects idx to be the
// insertion point.
idx++
// Copy the args
newArgs := make([]string, len(args)+len(extra))
copy(newArgs, args[:idx])
copy(newArgs[idx:], extra)
copy(newArgs[len(extra)+idx:], args[idx:])
return newArgs, nil
}
// parse information on reattaching to unmanaged providers out of a
// JSON-encoded environment variable.
func parseReattachProviders(in string) (map[addrs.Provider]*plugin.ReattachConfig, error) {

View File

@ -9,247 +9,12 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"github.com/mitchellh/cli"
)
func TestMain_cliArgsFromEnv(t *testing.T) {
// Set up the state. This test really messes with the environment and
// global state so we set things up to be restored.
// Restore original CLI args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test command and restore that
commands = make(map[string]cli.CommandFactory)
defer func() {
commands = nil
}()
testCommandName := "unit-test-cli-args"
testCommand := &testCommandCLI{}
commands[testCommandName] = func() (cli.Command, error) {
return testCommand, nil
}
cases := []struct {
Name string
Args []string
Value string
Expected []string
Err bool
}{
{
"no env",
[]string{testCommandName, "foo", "bar"},
"",
[]string{"foo", "bar"},
false,
},
{
"both env var and CLI",
[]string{testCommandName, "foo", "bar"},
"-foo baz",
[]string{"-foo", "baz", "foo", "bar"},
false,
},
{
"only env var",
[]string{testCommandName},
"-foo bar",
[]string{"-foo", "bar"},
false,
},
{
"cli string has blank values",
[]string{testCommandName, "bar", "", "baz"},
"-foo bar",
[]string{"-foo", "bar", "bar", "", "baz"},
false,
},
{
"cli string has blank values before the command",
[]string{"", testCommandName, "bar"},
"-foo bar",
[]string{"-foo", "bar", "bar"},
false,
},
{
// this should fail gracefully, this is just testing
// that we don't panic with our slice arithmetic
"no command",
[]string{},
"-foo bar",
nil,
true,
},
{
"single quoted strings",
[]string{testCommandName, "foo"},
"-foo 'bar baz'",
[]string{"-foo", "bar baz", "foo"},
false,
},
{
"double quoted strings",
[]string{testCommandName, "foo"},
`-foo "bar baz"`,
[]string{"-foo", "bar baz", "foo"},
false,
},
{
"double quoted single quoted strings",
[]string{testCommandName, "foo"},
`-foo "'bar baz'"`,
[]string{"-foo", "'bar baz'", "foo"},
false,
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) {
// Set the env var value
if tc.Value != "" {
t.Setenv(EnvCLI, tc.Value)
}
// Set up the args
args := make([]string, len(tc.Args)+1)
args[0] = oldArgs[0] // process name
copy(args[1:], tc.Args)
// Run it!
os.Args = args
testCommand.Args = nil
exit := realMain()
if (exit != 0) != tc.Err {
t.Fatalf("bad: %d", exit)
}
if tc.Err {
return
}
// Verify
if !reflect.DeepEqual(testCommand.Args, tc.Expected) {
t.Fatalf("expected args %#v but got %#v", tc.Expected, testCommand.Args)
}
})
}
}
// This test just has more options than the test above. Use this for
// more control over behavior at the expense of more complex test structures.
func TestMain_cliArgsFromEnvAdvanced(t *testing.T) {
// Restore original CLI args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test command and restore that
commands = make(map[string]cli.CommandFactory)
defer func() {
commands = nil
}()
cases := []struct {
Name string
Command string
EnvVar string
Args []string
Value string
Expected []string
Err bool
}{
{
"targeted to another command",
"command",
EnvCLI + "_foo",
[]string{"command", "foo", "bar"},
"-flag",
[]string{"foo", "bar"},
false,
},
{
"targeted to this command",
"command",
EnvCLI + "_command",
[]string{"command", "foo", "bar"},
"-flag",
[]string{"-flag", "foo", "bar"},
false,
},
{
"targeted to a command with a hyphen",
"command-name",
EnvCLI + "_command_name",
[]string{"command-name", "foo", "bar"},
"-flag",
[]string{"-flag", "foo", "bar"},
false,
},
{
"targeted to a command with a space",
"command name",
EnvCLI + "_command_name",
[]string{"command", "name", "foo", "bar"},
"-flag",
[]string{"-flag", "foo", "bar"},
false,
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) {
// Set up test command and restore that
testCommandName := tc.Command
testCommand := &testCommandCLI{}
defer func() { delete(commands, testCommandName) }()
commands[testCommandName] = func() (cli.Command, error) {
return testCommand, nil
}
// Set the env var value
if tc.Value != "" {
t.Setenv(tc.EnvVar, tc.Value)
}
// Set up the args
args := make([]string, len(tc.Args)+1)
args[0] = oldArgs[0] // process name
copy(args[1:], tc.Args)
// Run it!
os.Args = args
testCommand.Args = nil
exit := realMain()
if (exit != 0) != tc.Err {
t.Fatalf("unexpected exit status %d; want 0", exit)
}
if tc.Err {
return
}
// Verify
if !reflect.DeepEqual(testCommand.Args, tc.Expected) {
t.Fatalf("bad: %#v", testCommand.Args)
}
})
}
}
// verify that we output valid autocomplete results
func TestMain_autoComplete(t *testing.T) {
// Restore original CLI args

View File

@ -0,0 +1 @@
we don't expand `backtick sequences` because arbitrary command execution would be a significant security risk

View File

@ -0,0 +1 @@
we don't expand $(command substitutions) because arbitrary command execution would be a significant security risk

View File

@ -0,0 +1 @@
"no $INTERPOLATION inside double quotes"

View File

@ -0,0 +1,2 @@
"hello
world"

View File

@ -0,0 +1 @@
" "

View File

@ -0,0 +1 @@
"uh-oh

View File

@ -0,0 +1 @@
"beep boop" foo "blah blah"

View File

View File

@ -0,0 +1 @@
we $DONT expand things that look like ${ENVVAR}interpolations

View File

@ -0,0 +1,2 @@
can escape\ spaces and\
newlines and \"double quotes\" and \'single quotes\'!

View File

@ -0,0 +1 @@
'in quotes >we take <redirection metacharacters | literally 2>&and as a normal; part of the overall string'

View File

@ -0,0 +1 @@
not a shell, so >we take <redirection metacharacters | literally 2>&but still accept them; blah

View File

@ -0,0 +1,2 @@
bar baz
beep boop

View File

@ -0,0 +1,2 @@
"it's okay to have (parens) in double quotes"
'and (also) in single quotes'

View File

@ -0,0 +1 @@
we do not allow (parentheses)

View File

@ -0,0 +1,2 @@
'hello
world'

View File

@ -0,0 +1 @@
' '

View File

@ -0,0 +1 @@
'uh-oh

View File

@ -0,0 +1 @@
-var='something=foo" "bar'

View File

@ -0,0 +1 @@
-var-file=../bar/baz.tfvars

View File

@ -0,0 +1 @@
-foo bar baz

View File

@ -0,0 +1,3 @@
-var-file=..\bar\baz.tfvars
-var-file='..\bar\baz.tfvars'
-var-file=..\\bar\\baz.tfvars