diff --git a/internal/addrs/instance_key.go b/internal/addrs/instance_key.go index ff128be5b4..2d46bfcbe0 100644 --- a/internal/addrs/instance_key.go +++ b/internal/addrs/instance_key.go @@ -2,6 +2,8 @@ package addrs import ( "fmt" + "strings" + "unicode" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" @@ -72,9 +74,10 @@ func (k StringKey) instanceKeySigil() { } func (k StringKey) String() string { - // FIXME: This isn't _quite_ right because Go's quoted string syntax is - // slightly different than HCL's, but we'll accept it for now. - return fmt.Sprintf("[%q]", string(k)) + // We use HCL's quoting syntax here so that we can in principle parse + // an address constructed by this package as if it were an HCL + // traversal, even if the string contains HCL's own metacharacters. + return fmt.Sprintf("[%s]", toHCLQuotedString(string(k))) } func (k StringKey) Value() cty.Value { @@ -133,3 +136,56 @@ const ( IntKeyType InstanceKeyType = 'I' StringKeyType InstanceKeyType = 'S' ) + +// toHCLQuotedString is a helper which formats the given string in a way that +// HCL's expression parser would treat as a quoted string template. +// +// This includes: +// - Adding quote marks at the start and the end. +// - Using backslash escapes as needed for characters that cannot be represented directly. +// - Escaping anything that would be treated as a template interpolation or control sequence. +func toHCLQuotedString(s string) string { + // This is an adaptation of a similar function inside the hclwrite package, + // inlined here because hclwrite's version generates HCL tokens but we + // only need normal strings. + if len(s) == 0 { + return `""` + } + var buf strings.Builder + buf.WriteByte('"') + for i, r := range s { + switch r { + case '\n': + buf.WriteString(`\n`) + case '\r': + buf.WriteString(`\r`) + case '\t': + buf.WriteString(`\t`) + case '"': + buf.WriteString(`\"`) + case '\\': + buf.WriteString(`\\`) + case '$', '%': + buf.WriteRune(r) + remain := s[i+1:] + if len(remain) > 0 && remain[0] == '{' { + // Double up our template introducer symbol to escape it. + buf.WriteRune(r) + } + default: + if !unicode.IsPrint(r) { + var fmted string + if r < 65536 { + fmted = fmt.Sprintf("\\u%04x", r) + } else { + fmted = fmt.Sprintf("\\U%08x", r) + } + buf.WriteString(fmted) + } else { + buf.WriteRune(r) + } + } + } + buf.WriteByte('"') + return buf.String() +} diff --git a/internal/addrs/instance_key_test.go b/internal/addrs/instance_key_test.go new file mode 100644 index 0000000000..0d12888bf0 --- /dev/null +++ b/internal/addrs/instance_key_test.go @@ -0,0 +1,75 @@ +package addrs + +import ( + "fmt" + "testing" +) + +func TestInstanceKeyString(t *testing.T) { + tests := []struct { + Key InstanceKey + Want string + }{ + { + IntKey(0), + `[0]`, + }, + { + IntKey(5), + `[5]`, + }, + { + StringKey(""), + `[""]`, + }, + { + StringKey("hi"), + `["hi"]`, + }, + { + StringKey("0"), + `["0"]`, // intentionally distinct from IntKey(0) + }, + { + // Quotes must be escaped + StringKey(`"`), + `["\""]`, + }, + { + // Escape sequences must themselves be escaped + StringKey(`\r\n`), + `["\\r\\n"]`, + }, + { + // Template interpolation sequences "${" must be escaped. + StringKey(`${hello}`), + `["$${hello}"]`, + }, + { + // Template control sequences "%{" must be escaped. + StringKey(`%{ for something in something }%{ endfor }`), + `["%%{ for something in something }%%{ endfor }"]`, + }, + { + // Dollar signs that aren't followed by { are not interpolation sequences + StringKey(`$hello`), + `["$hello"]`, + }, + { + // Percent signs that aren't followed by { are not control sequences + StringKey(`%hello`), + `["%hello"]`, + }, + } + + for _, test := range tests { + testName := fmt.Sprintf("%#v", test.Key) + t.Run(testName, func(t *testing.T) { + got := test.Key.String() + want := test.Want + if got != want { + t.Errorf("wrong result\nreciever: %s\ngot: %s\nwant: %s", testName, got, want) + } + }) + } +}