terraform/internal/command/jsonformat/structured/attribute_path/matcher.go
James Bardin a5077e7ddb don't panic on invalid keys for relevant attrs
The PathMatcher used when rendering diffs assumes the caller knows the
structure the path was derived from, which is incorrect.
RelevantAttributes is derived from references to an object, which may
have errors hidden by `try` or `can` functions, or the data may not have
been updated to match paths via targeted operations.

Even in the case where the data may be incorrect, we can't crash when
rendering the data, because the user may not be able to work around the
panic with no other information about which resource contains unexpected
references.
2026-03-11 14:12:25 -04:00

239 lines
7.2 KiB
Go

// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package attribute_path
import (
"encoding/json"
"fmt"
"strconv"
)
// Matcher provides an interface for stepping through changes following an
// attribute path.
//
// GetChildWithKey and GetChildWithIndex will check if any of the internal paths
// match the provided key or index, and return a new Matcher that will match
// that children or potentially it's children.
//
// The caller of the above functions is required to know whether the next value
// in the path is a list type or an object type and call the relevant function,
// otherwise no match will be returned.
//
// The Matches function returns true if the paths you have traversed until now
// ends.
type Matcher interface {
// Matches returns true if we have reached the end of a path and found an
// exact match.
Matches() bool
// MatchesPartial returns true if the current attribute is part of a path
// but not necessarily at the end of the path.
MatchesPartial() bool
GetChildWithKey(key string) Matcher
GetChildWithIndex(index int) Matcher
}
// Parse accepts a json.RawMessage and outputs a formatted Matcher object.
//
// Parse expects the message to be a JSON array of JSON arrays containing
// strings and floats. This function happily accepts a null input representing
// none of the changes in this resource are causing a replacement. The propagate
// argument tells the matcher to propagate any matches to the matched attributes
// children.
//
// In general, this function is designed to accept messages that have been
// produced by the lossy cty.Paths conversion functions within the jsonplan
// package. There is nothing particularly special about that conversion process
// though, it just produces the nested JSON arrays described above.
func Parse(message json.RawMessage, propagate bool) Matcher {
matcher := &PathMatcher{
Propagate: propagate,
}
if message == nil {
return matcher
}
if err := json.Unmarshal(message, &matcher.Paths); err != nil {
panic("failed to unmarshal attribute paths: " + err.Error())
}
return matcher
}
// Empty returns an empty PathMatcher that will by default match nothing.
//
// We give direct access to the PathMatcher struct so a matcher can be built
// in parts with the Append and AppendSingle functions.
func Empty(propagate bool) *PathMatcher {
return &PathMatcher{
Propagate: propagate,
}
}
// Append accepts an existing PathMatcher and returns a new one that attaches
// all the paths from message with the existing paths.
//
// The new PathMatcher is created fresh, and the existing one is unchanged.
func Append(matcher *PathMatcher, message json.RawMessage) *PathMatcher {
var values [][]interface{}
if err := json.Unmarshal(message, &values); err != nil {
panic("failed to unmarshal attribute paths: " + err.Error())
}
return &PathMatcher{
Propagate: matcher.Propagate,
Paths: append(matcher.Paths, values...),
}
}
// AppendSingle accepts an existing PathMatcher and returns a new one that
// attaches the single path from message with the existing paths.
//
// The new PathMatcher is created fresh, and the existing one is unchanged.
func AppendSingle(matcher *PathMatcher, message json.RawMessage) *PathMatcher {
var values []interface{}
if err := json.Unmarshal(message, &values); err != nil {
panic("failed to unmarshal attribute paths: " + err.Error())
}
return &PathMatcher{
Propagate: matcher.Propagate,
Paths: append(matcher.Paths, values),
}
}
// PathMatcher contains a slice of paths that represent paths through the values
// to relevant/tracked attributes.
type PathMatcher struct {
// We represent our internal paths as a [][]interface{} as the cty.Paths
// conversion process is lossy. Since the type information is lost there
// is no (easy) way to reproduce the original cty.Paths object. Instead,
// we simply rely on the external callers to know the type information and
// call the correct GetChild function.
Paths [][]interface{}
// Propagate tells the matcher that it should propagate any matches it finds
// onto the children of that match.
Propagate bool
}
func (p *PathMatcher) Matches() bool {
for _, path := range p.Paths {
if len(path) == 0 {
return true
}
}
return false
}
func (p *PathMatcher) MatchesPartial() bool {
return len(p.Paths) > 0
}
func (p *PathMatcher) GetChildWithKey(key string) Matcher {
child := &PathMatcher{
Propagate: p.Propagate,
}
for _, path := range p.Paths {
if len(path) == 0 {
// This means that the current value matched, but not necessarily
// it's child.
if p.Propagate {
// If propagate is true, then our child match our matches
child.Paths = append(child.Paths, path)
}
// If not we would simply drop this path from our set of paths but
// either way we just continue.
continue
}
switch val := path[0].(type) {
case string:
if val == key {
child.Paths = append(child.Paths, path[1:])
}
case float64:
// here we must assume the path being looked up no longer matches
// the given data structure, so the caller in incorrect. This is
// fine, because it only means that we don't match any paths.
default:
panic(fmt.Errorf("found invalid type within path (%v:%T), the validation shouldn't have allowed this to happen; this is a bug in Terraform, please report it", val, val))
}
}
return child
}
func (p *PathMatcher) GetChildWithIndex(index int) Matcher {
child := &PathMatcher{
Propagate: p.Propagate,
}
for _, path := range p.Paths {
if len(path) == 0 {
// This means that the current value matched, but not necessarily
// it's child.
if p.Propagate {
// If propagate is true, then our child match our matches
child.Paths = append(child.Paths, path)
}
// If not we would simply drop this path from our set of paths but
// either way we just continue.
continue
}
// Terraform actually allows user to provide strings into indexes as
// long as the string can be interpreted into a number. For example, the
// following are equivalent and we need to support them.
// - test_resource.resource.list[0].attribute
// - test_resource.resource.list["0"].attribute
//
// Note, that Terraform will raise a validation error if the string
// can't be coerced into a number, so we will panic here if anything
// goes wrong safe in the knowledge the validation should stop this from
// happening.
switch val := path[0].(type) {
case float64:
if int(val) == index {
child.Paths = append(child.Paths, path[1:])
}
case string:
f, err := strconv.ParseFloat(val, 64)
if err == nil && int(f) == index {
child.Paths = append(child.Paths, path[1:])
}
default:
panic(fmt.Errorf("found invalid type within path (%v:%T), the validation shouldn't have allowed this to happen; this is a bug in Terraform, please report it", val, val))
}
}
return child
}
// AlwaysMatcher returns a matcher that will always match all paths.
func AlwaysMatcher() Matcher {
return &alwaysMatcher{}
}
type alwaysMatcher struct{}
func (a *alwaysMatcher) Matches() bool {
return true
}
func (a *alwaysMatcher) MatchesPartial() bool {
return true
}
func (a *alwaysMatcher) GetChildWithKey(_ string) Matcher {
return a
}
func (a *alwaysMatcher) GetChildWithIndex(_ int) Matcher {
return a
}