// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 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 these functions will crash/panic. // // 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 } if path[0].(string) == key { child.Paths = append(child.Paths, path[1:]) } } 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 } // OpenTofu 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 OpenTofu 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(path[0].(float64)) == index { child.Paths = append(child.Paths, path[1:]) } case string: f, err := strconv.ParseFloat(val, 64) if err != nil { panic(fmt.Errorf("found invalid type within path (%v:%T), the validation shouldn't have allowed this to happen; this is a bug in OpenTofu, please report it", val, val)) } if 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 OpenTofu, 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 }