diff --git a/tftypes/refinement/collection_length_lower_bound.go b/tftypes/refinement/collection_length_lower_bound.go new file mode 100644 index 00000000..94ddc554 --- /dev/null +++ b/tftypes/refinement/collection_length_lower_bound.go @@ -0,0 +1,40 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// CollectionLengthLowerBound represents an unknown value refinement which indicates the length of the final collection value will be +// at least the specified int64 value. This refinement can only be applied to List, Map, and Set types. +type CollectionLengthLowerBound struct { + value int64 +} + +func (n CollectionLengthLowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(CollectionLengthLowerBound) + if !ok { + return false + } + + return n.LowerBound() == otherVal.LowerBound() +} + +func (n CollectionLengthLowerBound) String() string { + return fmt.Sprintf("length lower bound = %d", n.LowerBound()) +} + +// LowerBound returns the int64 value that the final value's collection length will be at least. +func (n CollectionLengthLowerBound) LowerBound() int64 { + return n.value +} + +func (n CollectionLengthLowerBound) unimplementable() {} + +// NewCollectionLengthLowerBound returns the CollectionLengthLowerBound unknown value refinement which indicates the length of the final +// collection value will be at least the specified int64 value. This refinement can only be applied to List, Map, and Set types. +func NewCollectionLengthLowerBound(value int64) Refinement { + return CollectionLengthLowerBound{ + value: value, + } +} diff --git a/tftypes/refinement/collection_length_upper_bound.go b/tftypes/refinement/collection_length_upper_bound.go new file mode 100644 index 00000000..889250d2 --- /dev/null +++ b/tftypes/refinement/collection_length_upper_bound.go @@ -0,0 +1,40 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// CollectionLengthUpperBound represents an unknown value refinement which indicates the length of the final collection value will be +// at most the specified int64 value. This refinement can only be applied to List, Map, and Set types. +type CollectionLengthUpperBound struct { + value int64 +} + +func (n CollectionLengthUpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(CollectionLengthUpperBound) + if !ok { + return false + } + + return n.UpperBound() == otherVal.UpperBound() +} + +func (n CollectionLengthUpperBound) String() string { + return fmt.Sprintf("length upper bound = %d", n.UpperBound()) +} + +// UpperBound returns the int64 value that the final value's collection length will be at most. +func (n CollectionLengthUpperBound) UpperBound() int64 { + return n.value +} + +func (n CollectionLengthUpperBound) unimplementable() {} + +// NewCollectionLengthUpperBound returns the CollectionLengthUpperBound unknown value refinement which indicates the length of the final +// collection value will be at most the specified int64 value. This refinement can only be applied to List, Map, and Set types. +func NewCollectionLengthUpperBound(value int64) Refinement { + return CollectionLengthUpperBound{ + value: value, + } +} diff --git a/tftypes/refinement/doc.go b/tftypes/refinement/doc.go new file mode 100644 index 00000000..eba8d649 --- /dev/null +++ b/tftypes/refinement/doc.go @@ -0,0 +1,11 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// The refinement package contains the interfaces and structs that represent unknown value refinement data. Refinements contain +// additional constraints about unknown values and what their eventual known values can be. In certain scenarios, Terraform can +// use these constraints to produce known results from unknown values. (like evaluating a count expression comparing an unknown +// value to "null") +// +// Unknown value refinements can be added to a `tftypes.Value` via the `(tftypes.Value).Refine` method. Refinements on an unknown +// `tftypes.Value` can be retrieved via the `(tftypes.Value).Refinements()` method. +package refinement diff --git a/tftypes/refinement/nullness.go b/tftypes/refinement/nullness.go new file mode 100644 index 00000000..d74e6440 --- /dev/null +++ b/tftypes/refinement/nullness.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +// Nullness represents an unknown value refinement that indicates the final value will definitely not be null (Nullness = false). This refinement +// can be applied to a value of any type (excluding DynamicPseudoType). +// +// While an unknown value can be refined to indicate that the final value will definitely be null (Nullness = true), there is no practical reason +// to do this. This option is exposed to maintain parity with Terraform's type system, while all practical usages of this refinement should collapse +// to known null values instead. +type Nullness struct { + value bool +} + +func (n Nullness) Equal(other Refinement) bool { + otherVal, ok := other.(Nullness) + if !ok { + return false + } + + return n.Nullness() == otherVal.Nullness() +} + +func (n Nullness) String() string { + if n.value { + // This case should never happen, as an unknown value that is definitely null should be + // represented as a known null value. + return "null" + } + + return "not null" +} + +// Nullness returns the underlying refinement data indicating: +// - When "false", the final value will definitely not be null. +// - When "true", the final value will definitely be null. +// +// While an unknown value can be refined to indicate that the final value will definitely be null (Nullness = true), there is no practical reason +// to do this. This option is exposed to maintain parity with Terraform's type system, while all practical usages of this refinement should collapse +// to known null values instead. +func (n Nullness) Nullness() bool { + return n.value +} + +func (n Nullness) unimplementable() {} + +// NewNullness returns the Nullness unknown value refinement that indicates the final value will definitely not be null (Nullness = false). This refinement +// can be applied to a value of any type (excluding DynamicPseudoType). +// +// While an unknown value can be refined to indicate that the final value will definitely be null (Nullness = true), there is no practical reason +// to do this. This option is exposed to maintain parity with Terraform's type system, while all practical usages of this refinement should collapse +// to known null values instead. +func NewNullness(value bool) Refinement { + return Nullness{ + value: value, + } +} diff --git a/tftypes/refinement/number_lower_bound.go b/tftypes/refinement/number_lower_bound.go new file mode 100644 index 00000000..0fe83f78 --- /dev/null +++ b/tftypes/refinement/number_lower_bound.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import ( + "fmt" + "math/big" +) + +// NumberLowerBound represents an unknown value refinement that indicates the final value will not be less than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to the Number type. +type NumberLowerBound struct { + inclusive bool + value *big.Float +} + +func (n NumberLowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(NumberLowerBound) + if !ok { + return false + } + + return n.IsInclusive() == otherVal.IsInclusive() && n.LowerBound().Cmp(otherVal.LowerBound()) == 0 +} + +func (n NumberLowerBound) String() string { + rangeDescription := "inclusive" + if !n.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %s (%s)", n.LowerBound().String(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (n NumberLowerBound) IsInclusive() bool { + return n.inclusive +} + +// LowerBound returns the *big.Float value that the final value will not be less than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (n NumberLowerBound) LowerBound() *big.Float { + return n.value +} + +func (n NumberLowerBound) unimplementable() {} + +// NewNumberLowerBound returns the NumberLowerBound unknown value refinement that indicates the final value will not be less than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to the Number type. +func NewNumberLowerBound(value *big.Float, inclusive bool) Refinement { + return NumberLowerBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/tftypes/refinement/number_upper_bound.go b/tftypes/refinement/number_upper_bound.go new file mode 100644 index 00000000..601e3284 --- /dev/null +++ b/tftypes/refinement/number_upper_bound.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import ( + "fmt" + "math/big" +) + +// NumberUpperBound represents an unknown value refinement that indicates the final value will not be greater than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to the Number type. +type NumberUpperBound struct { + inclusive bool + value *big.Float +} + +func (n NumberUpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(NumberUpperBound) + if !ok { + return false + } + + return n.IsInclusive() == otherVal.IsInclusive() && n.UpperBound().Cmp(otherVal.UpperBound()) == 0 +} + +func (n NumberUpperBound) String() string { + rangeDescription := "inclusive" + if !n.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %s (%s)", n.UpperBound().String(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (n NumberUpperBound) IsInclusive() bool { + return n.inclusive +} + +// UpperBound returns the *big.Float value that the final value will not be greater than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (n NumberUpperBound) UpperBound() *big.Float { + return n.value +} + +func (n NumberUpperBound) unimplementable() {} + +// NewNumberUpperBound returns the NumberUpperBound unknown value refinement that indicates the final value will not be greater than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to the Number type. +func NewNumberUpperBound(value *big.Float, inclusive bool) Refinement { + return NumberUpperBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/tftypes/refinement/refinement.go b/tftypes/refinement/refinement.go new file mode 100644 index 00000000..0b5b2517 --- /dev/null +++ b/tftypes/refinement/refinement.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import ( + "fmt" + "sort" + "strings" +) + +type Key int64 + +func (k Key) String() string { + switch k { + case KeyNullness: + return "nullness" + case KeyStringPrefix: + return "string_prefix" + case KeyNumberLowerBound: + return "number_lower_bound" + case KeyNumberUpperBound: + return "number_upper_bound" + case KeyCollectionLengthLowerBound: + return "collection_length_lower_bound" + case KeyCollectionLengthUpperBound: + return "collection_length_upper_bound" + default: + return fmt.Sprintf("unsupported refinement: %d", k) + } +} + +const ( + // KeyNullness represents a refinement that specifies whether the final value will not be null. + // + // MAINTINAER NOTE: In practice, this refinement data will only contain "false", indicating the final value + // cannot be null. If the refinement data was ever set to "true", that would indicate the final value will be null, in which + // case the value is not unknown, it is known and should not have any refinement data. + // + // This refinement is relevant for all types except tftypes.DynamicPseudoType. + KeyNullness = Key(1) + + // KeyStringPrefix represents a refinement that specifies a known prefix of a final string value. + // + // This refinement is only relevant for tftypes.String. + KeyStringPrefix = Key(2) + + // KeyNumberLowerBound represents a refinement that specifies the lower bound of possible values for a final number value. + // The refinement data contains a boolean which indicates whether the bound is inclusive. + // + // This refinement is only relevant for tftypes.Number. + KeyNumberLowerBound = Key(3) + + // KeyNumberUpperBound represents a refinement that specifies the upper bound of possible values for a final number value. + // The refinement data contains a boolean which indicates whether the bound is inclusive. + // + // This refinement is only relevant for tftypes.Number. + KeyNumberUpperBound = Key(4) + + // KeyCollectionLengthLowerBound represents a refinement that specifies the lower bound of possible length for a final collection value. + // + // This refinement is only relevant for tftypes.List, tftypes.Set, and tftypes.Map. + KeyCollectionLengthLowerBound = Key(5) + + // KeyCollectionLengthUpperBound represents a refinement that specifies the upper bound of possible length for a final collection value. + // + // This refinement is only relevant for tftypes.List, tftypes.Set, and tftypes.Map. + KeyCollectionLengthUpperBound = Key(6) +) + +// Refinement represents an unknown value refinement with data constraints relevant to the final value. This interface can be asserted further +// with the associated structs in the `refinement` package to extract underlying refinement data. +type Refinement interface { + // Equal should return true if the Refinement is considered equivalent to the + // Refinement passed as an argument. + Equal(Refinement) bool + + // String should return a human-friendly version of the Refinement. + String() string + + unimplementable() // prevents external implementations, all refinements are defined in the Terraform/HCL type system go-cty. +} + +// Refinements represents a map of unknown value refinement data. +type Refinements map[Key]Refinement + +func (r Refinements) Equal(other Refinements) bool { + if len(r) != len(other) { + return false + } + + for key, refnVal := range r { + otherRefnVal, ok := other[key] + if !ok { + // Didn't find a refinement at the same key + return false + } + + if !refnVal.Equal(otherRefnVal) { + // Refinement data is not equal + return false + } + } + + return true +} +func (r Refinements) String() string { + var res strings.Builder + + keys := make([]Key, 0, len(r)) + for k := range r { + keys = append(keys, k) + } + + sort.Slice(keys, func(a, b int) bool { return keys[a] < keys[b] }) + for pos, key := range keys { + if pos != 0 { + res.WriteString(", ") + } + res.WriteString(r[key].String()) + } + + return res.String() +} diff --git a/tftypes/refinement/string_prefix.go b/tftypes/refinement/string_prefix.go new file mode 100644 index 00000000..5852262d --- /dev/null +++ b/tftypes/refinement/string_prefix.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// StringPrefix represents an unknown value refinement that indicates the final value will be prefixed with the specified string value. +// String prefixes that exceed 256 characters in length will be truncated and empty string prefixes will not be encoded. This refinement can +// only be applied to the String type. +type StringPrefix struct { + value string +} + +func (s StringPrefix) Equal(other Refinement) bool { + otherVal, ok := other.(StringPrefix) + if !ok { + return false + } + + return s.PrefixValue() == otherVal.PrefixValue() +} + +func (s StringPrefix) String() string { + return fmt.Sprintf("prefix = %q", s.PrefixValue()) +} + +// PrefixValue returns the string value that the final value will be prefixed with. +func (s StringPrefix) PrefixValue() string { + return s.value +} + +func (s StringPrefix) unimplementable() {} + +// NewStringPrefix returns the StringPrefix unknown value refinement that indicates the final value will be prefixed with the specified +// string value. String prefixes that exceed 256 characters in length will be truncated and empty string prefixes will not be encoded. This +// refinement can only be applied to the String type. +func NewStringPrefix(value string) Refinement { + return StringPrefix{ + value: value, + } +} diff --git a/tftypes/value.go b/tftypes/value.go index 63570211..8b96b3d5 100644 --- a/tftypes/value.go +++ b/tftypes/value.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" msgpack "github.com/vmihailenco/msgpack/v5" ) @@ -44,6 +45,10 @@ type ValueCreator interface { type Value struct { typ Type value interface{} + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } func (val Value) String() string { @@ -57,8 +62,17 @@ func (val Value) String() string { if val.IsNull() { return typ.String() + "" } + if !val.IsKnown() { - return typ.String() + "" + var res strings.Builder + res.WriteString(typ.String()) + res.WriteString(" 0 { + res.WriteString(", " + val.Refinements().String()) + } + res.WriteString(">") + + return res.String() } // everything else is built up @@ -221,6 +235,15 @@ func (val Value) Equal(o Value) bool { if !val.Type().Equal(o.Type()) { return false } + + if len(val.refinements) != len(o.refinements) { + return false + } + + if len(val.refinements) > 0 && !val.refinements.Equal(o.refinements) { + return false + } + deepEqual, err := val.deepEqual(o) if err != nil { return false @@ -247,7 +270,19 @@ func (val Value) Copy() Value { } newVal = newVals } - return NewValue(val.Type(), newVal) + + newTfValue := NewValue(val.Type(), newVal) + + if len(val.refinements) > 0 { + newRefinements := make(refinement.Refinements, len(val.refinements)) + for key, refnVal := range val.refinements { + newRefinements[key] = refnVal + } + + newTfValue.refinements = newRefinements + } + + return newTfValue } // NewValue returns a Value constructed using the specified Type and stores the @@ -592,3 +627,34 @@ func (val Value) MarshalMsgPack(t Type) ([]byte, error) { func unexpectedValueTypeError(p *AttributePath, expected, got interface{}, typ Type) error { return p.NewErrorf("unexpected value type %T, %s values must be of type %T", got, typ, expected) } + +// Refine is used to apply unknown refinement data to a Value. This method will return a copy of the Value, fully +// replacing all the existing refinement data with the provided refinements. +// +// If the Value is not unknown, then a copy of the Value will be returned with no refinements. +func (val Value) Refine(refinements refinement.Refinements) Value { + newVal := val.Copy() + + // Refinements are only relevant for unknown values + if val.IsKnown() { + return newVal + } + + if len(refinements) >= 0 { + newRefinements := make(refinement.Refinements, len(refinements)) + for key, refnVal := range refinements { + newRefinements[key] = refnVal + } + newVal.refinements = newRefinements + } + + return newVal +} + +// Refinements returns the unknown refinement data for the Value. +func (val Value) Refinements() refinement.Refinements { + // Copy the data first to prevent any mutation of the refinement map data. + valCopy := val.Copy() + + return valCopy.refinements +} diff --git a/tftypes/value_msgpack.go b/tftypes/value_msgpack.go index 08fb1520..4ac22b4a 100644 --- a/tftypes/value_msgpack.go +++ b/tftypes/value_msgpack.go @@ -6,14 +6,19 @@ package tftypes import ( "bytes" "fmt" + "io" "math" "math/big" "sort" + "unicode/utf8" + "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" msgpack "github.com/vmihailenco/msgpack/v5" msgpackCodes "github.com/vmihailenco/msgpack/v5/msgpcode" ) +const unknownWithRefinementsExt = 0x0c + type msgPackUnknownType struct{} var msgPackUnknownVal = msgPackUnknownType{} @@ -43,11 +48,7 @@ func msgpackUnmarshal(dec *msgpack.Decoder, typ Type, path *AttributePath) (Valu } if msgpackCodes.IsExt(peek) { // as with go-cty, assume all extensions are unknown values - err := dec.Skip() - if err != nil { - return Value{}, path.NewErrorf("error skipping extension byte: %w", err) - } - return NewValue(typ, UnknownValue), nil + return msgpackUnmarshalUnknown(dec, typ, path) } if typ.Is(DynamicPseudoType) { return msgpackUnmarshalDynamic(dec, path) @@ -344,6 +345,157 @@ func msgpackUnmarshalDynamic(dec *msgpack.Decoder, path *AttributePath) (Value, } return msgpackUnmarshal(dec, typ, path) } +func msgpackUnmarshalUnknown(dec *msgpack.Decoder, typ Type, path *AttributePath) (Value, error) { + // The value is unknown, but we will check the extension header to see if any + // type-specific refinements are applied to the value. + typeCode, extLen, err := dec.DecodeExtHeader() + if err != nil { + return Value{}, path.NewErrorf("error decoding extension header: %w", err) + } + + if extLen <= 1 { + // If the extension is zero or one-length, this is a wholly unknown value with no + // refinements. + + if extLen > 0 { + // Skip the body + err = dec.Skip() + if err != nil { + return Value{}, path.NewErrorf("error skipping extension body: %w", err) + } + } + + return NewValue(typ, UnknownValue), nil + } + + // Check if the extension is the designated cty unknown refinement code + if typeCode != unknownWithRefinementsExt { + return Value{}, path.NewErrorf("unsupported extension type") + } + + if extLen > 1024 { + // cty refinements cannot be greater than 1 kiB + return Value{}, path.NewErrorf("unknown value refinement too large") + } + + // Unknown value refinements are always a msgpack-encoded map + body := make([]byte, extLen) + _, err = io.ReadAtLeast(dec.Buffered(), body, len(body)) + if err != nil { + return Value{}, path.NewErrorf("error reading msgpack extension body: %s", err) + } + + rfnDec := msgpack.NewDecoder(bytes.NewReader(body)) + entryCount, err := rfnDec.DecodeMapLen() + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: not a map") + } + + // cty ignores all refinements for DynamicPseudoType at the moment, this allows them to be introduced + // in the future in a backward compatible way. + if typ.Is(DynamicPseudoType) { + return NewValue(typ, UnknownValue), nil + } + + newVal := NewValue(typ, UnknownValue) + newRefinements := make(refinement.Refinements, 0) + + for i := 0; i < entryCount; i++ { + keyCode, err := rfnDec.DecodeInt64() + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: non-integer key in map") + } + + switch keyCode := refinement.Key(keyCode); keyCode { + case refinement.KeyNullness: + isNull, err := rfnDec.DecodeBool() + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: null refinement is not boolean") + } + + // isNull should always be false if this refinement is present, but to match cty's support + // of this encoding, we will pass along the value. If isNull is true, then the value should not be + // unknown with refinements, it should be a known null value. + newRefinements[keyCode] = refinement.NewNullness(isNull) + case refinement.KeyStringPrefix: + if !typ.Is(String) { + return Value{}, path.NewErrorf("error decoding msgpack extension body: string prefix refinement for non-string type") + } + prefix, err := rfnDec.DecodeString() + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: string prefix refinement is not string") + } + if !utf8.ValidString(prefix) { + return Value{}, path.NewErrorf("error decoding msgpack extension body: string prefix refinement is not valid UTF-8") + } + + newRefinements[keyCode] = refinement.NewStringPrefix(prefix) + case refinement.KeyNumberLowerBound, refinement.KeyNumberUpperBound: + if !typ.Is(Number) { + return Value{}, path.NewErrorf("error decoding msgpack extension body: numeric bound refinement for non-number type") + } + + // Numeric bound refinements are always a tuple of [number, bool] so we re-use the msgpack decoding logic on this refinement. + tfValBound, err := msgpackUnmarshal(rfnDec, Tuple{ElementTypes: []Type{Number, Bool}}, path) + if err != nil || tfValBound.IsNull() || !tfValBound.IsKnown() { + return Value{}, path.NewErrorf("error decoding msgpack extension body: numeric bound refinement must be [number, bool] tuple") + } + + tupleVal := make([]Value, 2) + err = tfValBound.As(&tupleVal) + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: numeric bound refinement tuple value conversion failed: %w", err) + } + + if len(tupleVal) != 2 { + return Value{}, path.NewErrorf("error decoding msgpack extension body: numeric bound refinement tuple value conversion failed: expected 2 elements, got %d elements", len(tupleVal)) + } + + var boundVal *big.Float + err = tupleVal[0].As(&boundVal) + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: numeric bound refinement bound value conversion failed: %w", err) + } + + var inclusiveVal bool + err = tupleVal[1].As(&inclusiveVal) + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: numeric bound refinement inclusive value conversion failed: %w", err) + } + + if keyCode == refinement.KeyNumberLowerBound { + newRefinements[keyCode] = refinement.NewNumberLowerBound(boundVal, inclusiveVal) + } else { + newRefinements[keyCode] = refinement.NewNumberUpperBound(boundVal, inclusiveVal) + } + case refinement.KeyCollectionLengthLowerBound, refinement.KeyCollectionLengthUpperBound: + if !typ.Is(List{}) && !typ.Is(Map{}) && !typ.Is(Set{}) { + return Value{}, path.NewErrorf("error decoding msgpack extension body: length bound refinement for non-collection type") + } + + boundVal, err := rfnDec.DecodeInt() + if err != nil { + return Value{}, path.NewErrorf("error decoding msgpack extension body: length bound refinement must be integer") + } + + if keyCode == refinement.KeyCollectionLengthLowerBound { + newRefinements[keyCode] = refinement.NewCollectionLengthLowerBound(int64(boundVal)) + } else { + newRefinements[keyCode] = refinement.NewCollectionLengthUpperBound(int64(boundVal)) + } + default: + err := rfnDec.Skip() + if err != nil { + return Value{}, path.NewErrorf("error skipping unknown extension body, keycode = %d: %w", keyCode, err) + } + // We don't want to error here, as cty could introduce new refinements that we'd + // want to just ignore until this logic is updated + continue + } + } + + return newVal.Refine(newRefinements), nil +} func marshalMsgPack(val Value, typ Type, p *AttributePath, enc *msgpack.Encoder) error { if typ.Is(DynamicPseudoType) && !val.Type().Is(DynamicPseudoType) { @@ -351,11 +503,7 @@ func marshalMsgPack(val Value, typ Type, p *AttributePath, enc *msgpack.Encoder) } if !val.IsKnown() { - err := enc.Encode(msgPackUnknownVal) - if err != nil { - return p.NewErrorf("error encoding UnknownValue: %w", err) - } - return nil + return marshalUnknownValue(val, typ, p, enc) } if val.IsNull() { err := enc.EncodeNil() @@ -390,6 +538,210 @@ func marshalMsgPack(val Value, typ Type, p *AttributePath, enc *msgpack.Encoder) return fmt.Errorf("unknown type %s", typ) } +func marshalUnknownValue(val Value, typ Type, p *AttributePath, enc *msgpack.Encoder) error { + // Use the representation of a wholly unknown value if there are no refinements. DynamicPseudoType + // cannot have refinements, so it will also use the wholly unknown value. + if len(val.refinements) == 0 || typ.Is(DynamicPseudoType) { + err := enc.Encode(msgPackUnknownVal) + if err != nil { + return p.NewErrorf("error encoding UnknownValue: %w", err) + } + return nil + } + + var refnBuf bytes.Buffer + refnEnc := msgpack.NewEncoder(&refnBuf) + mapLen := 0 + + // Nullness refinement applies to all types except for DynamicPseudoType (handled above) + if refnVal, ok := val.refinements[refinement.KeyNullness]; ok { + data, ok := refnVal.(refinement.Nullness) + if !ok { + return p.NewErrorf("error encoding Nullness value refinement: unexpected refinement data of type %T", refnVal) + } + + err := refnEnc.EncodeInt(int64(refinement.KeyNullness)) + if err != nil { + return p.NewErrorf("error encoding Nullness value refinement key: %w", err) + } + + // It shouldn't be possible for an unknown value to be definitely null (i.e. Nullness.value = true), + // as that should be represented by a known null value instead. This encoding is in place to be compliant + // with Terraform's encoding which uses a definitely null refinement to collapse into a known null value. + err = refnEnc.EncodeBool(data.Nullness()) + if err != nil { + return p.NewErrorf("error encoding Nullness value refinement: %w", err) + } + + mapLen++ + } + + // Refinements for strings + if typ.Is(String) { + if refnVal, ok := val.refinements[refinement.KeyStringPrefix]; ok { + data, ok := refnVal.(refinement.StringPrefix) + if !ok { + return p.NewErrorf("error encoding StringPrefix value refinement: unexpected refinement data of type %T", refnVal) + } + + if prefix := data.PrefixValue(); prefix != "" { + // Matching cty for the max prefix length allowed here. + // + // This ensures the total size of the refinements blob does not exceed the limit + // set by the decoder (1024). + maxPrefixLength := 256 + if len(prefix) > maxPrefixLength { + prefix = prefix[:maxPrefixLength-1] + } + + err := refnEnc.EncodeInt(int64(refinement.KeyStringPrefix)) + if err != nil { + return p.NewErrorf("error encoding StringPrefix value refinement key: %w", err) + } + + err = refnEnc.EncodeString(prefix) + if err != nil { + return p.NewErrorf("error encoding StringPrefix value refinement: %w", err) + } + + mapLen++ + } + } + } + + // Refinements for numbers + if typ.Is(Number) { + if refnVal, ok := val.refinements[refinement.KeyNumberLowerBound]; ok { + data, ok := refnVal.(refinement.NumberLowerBound) + if !ok { + return p.NewErrorf("error encoding NumberLowerBound value refinement: unexpected refinement data of type %T", refnVal) + } + + // Numeric bound refinements are always a tuple of [number, bool] so we re-use the msgpack encoding logic on this refinement. + boundTfType := Tuple{ElementTypes: []Type{Number, Bool}} + boundTfVal := NewValue( + boundTfType, + []Value{ + NewValue(Number, data.LowerBound()), + NewValue(Bool, data.IsInclusive()), + }, + ) + + err := refnEnc.EncodeInt(int64(refinement.KeyNumberLowerBound)) + if err != nil { + return p.NewErrorf("error encoding NumberLowerBound value refinement key: %w", err) + } + + err = marshalMsgPack(boundTfVal, boundTfType, p, refnEnc) + if err != nil { + return p.NewErrorf("error encoding NumberLowerBound value refinement: %w", err) + } + + mapLen++ + } + + if refnVal, ok := val.refinements[refinement.KeyNumberUpperBound]; ok { + data, ok := refnVal.(refinement.NumberUpperBound) + if !ok { + return p.NewErrorf("error encoding NumberUpperBound value refinement: unexpected refinement data of type %T", refnVal) + } + + // Numeric bound refinements are always a tuple of [number, bool] so we re-use the msgpack encoding logic on this refinement. + boundTfType := Tuple{ElementTypes: []Type{Number, Bool}} + boundTfVal := NewValue( + boundTfType, + []Value{ + NewValue(Number, data.UpperBound()), + NewValue(Bool, data.IsInclusive()), + }, + ) + + err := refnEnc.EncodeInt(int64(refinement.KeyNumberUpperBound)) + if err != nil { + return p.NewErrorf("error encoding NumberUpperBound value refinement key: %w", err) + } + + err = marshalMsgPack(boundTfVal, boundTfType, p, refnEnc) + if err != nil { + return p.NewErrorf("error encoding NumberUpperBound value refinement: %w", err) + } + + mapLen++ + } + } + + // Refinements for collections + if typ.Is(List{}) || typ.Is(Map{}) || typ.Is(Set{}) { + if refnVal, ok := val.refinements[refinement.KeyCollectionLengthLowerBound]; ok { + data, ok := refnVal.(refinement.CollectionLengthLowerBound) + if !ok { + return p.NewErrorf("error encoding CollectionLengthLowerBound value refinement: unexpected refinement data of type %T", refnVal) + } + + err := refnEnc.EncodeInt(int64(refinement.KeyCollectionLengthLowerBound)) + if err != nil { + return p.NewErrorf("error encoding CollectionLengthLowerBound value refinement key: %w", err) + } + + err = refnEnc.EncodeInt(data.LowerBound()) + if err != nil { + return p.NewErrorf("error encoding CollectionLengthLowerBound value refinement: %w", err) + } + + mapLen++ + } + + if refnVal, ok := val.refinements[refinement.KeyCollectionLengthUpperBound]; ok { + data, ok := refnVal.(refinement.CollectionLengthUpperBound) + if !ok { + return p.NewErrorf("error encoding CollectionLengthUpperBound value refinement: unexpected refinement data of type %T", refnVal) + } + err := refnEnc.EncodeInt(int64(refinement.KeyCollectionLengthUpperBound)) + if err != nil { + return p.NewErrorf("error encoding CollectionLengthUpperBound value refinement key: %w", err) + } + + err = refnEnc.EncodeInt(data.UpperBound()) + if err != nil { + return p.NewErrorf("error encoding CollectionLengthUpperBound value refinement: %w", err) + } + + mapLen++ + } + } + + if mapLen == 0 { + // Didn't find any refinements we know how to encode, use the wholly unknown value. + err := enc.Encode(msgPackUnknownVal) + if err != nil { + return p.NewErrorf("error encoding UnknownValue: %w", err) + } + return nil + } + + // Encode all of the unknown value refinements + var lenBuf bytes.Buffer + lenEnc := msgpack.NewEncoder(&lenBuf) + lenEnc.EncodeMapLen(mapLen) //nolint + + err := enc.EncodeExtHeader(unknownWithRefinementsExt, lenBuf.Len()+refnBuf.Len()) + if err != nil { + return p.NewErrorf("error encoding UnknownValue with refinements: %s", err) + } + + _, err = enc.Writer().Write(lenBuf.Bytes()) + if err != nil { + return p.NewErrorf("error encoding UnknownValue with refinements: %s", err) + } + + _, err = enc.Writer().Write(refnBuf.Bytes()) + if err != nil { + return p.NewErrorf("error encoding UnknownValue with refinements: %s", err) + } + + return nil +} + func marshalMsgPackDynamicPseudoType(val Value, _ Type, p *AttributePath, enc *msgpack.Encoder) error { typeJSON, err := val.Type().MarshalJSON() if err != nil { diff --git a/tftypes/value_msgpack_test.go b/tftypes/value_msgpack_test.go index 778c073e..03dfaa43 100644 --- a/tftypes/value_msgpack_test.go +++ b/tftypes/value_msgpack_test.go @@ -5,11 +5,14 @@ package tftypes import ( "encoding/hex" + "errors" "math" "math/big" + "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestValueFromMsgPack(t *testing.T) { @@ -532,12 +535,126 @@ func TestValueFromMsgPack(t *testing.T) { }), typ: List{ElementType: DynamicPseudoType}, }, + // This encoding, while unlikely in practice, is supported. Terraform should collapse this to a known null value before reaching providers. + "unknown-with-nullness-true": { + hex: "c7030c8101c3", + value: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(true), + }), + typ: Bool, + }, + "unknown-bool-with-nullness-refinement": { + hex: "c7030c8101c2", + value: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + typ: Bool, + }, + "unknown-number-with-nullness-refinement": { + hex: "c7030c8101c2", + value: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + typ: Number, + }, + "unknown-string-with-nullness-refinement": { + hex: "c7030c8101c2", + value: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + typ: String, + }, + "unknown-list-with-nullness-refinement": { + hex: "c7030c8101c2", + value: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + typ: List{ElementType: String}, + }, + "unknown-object-with-nullness-refinement": { + hex: "c7030c8101c2", + value: NewValue(Object{AttributeTypes: map[string]Type{"attr": String}}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + typ: Object{AttributeTypes: map[string]Type{"attr": String}}, + }, + "unknown-string-with-prefix-refinement": { + hex: "c70e0c8201c202a97072656669783a2f2f", + value: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix("prefix://"), + }), + typ: String, + }, + "unknown-number-with-bound-refinements-integers-inclusive": { + hex: "c70b0c8301c2039201c3049205c3", + value: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), true), + }), + typ: Number, + }, + "unknown-number-with-bound-refinements-integers-exclusive": { + hex: "c70b0c8301c2039201c2049205c2", + value: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), false), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), false), + }), + typ: Number, + }, + "unknown-number-with-bound-refinements-float-inclusive": { + hex: "c71b0c8301c20392cb3ff3ae147ae147aec30492cb4016ae147ae147aec3", + value: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1.23), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5.67), true), + }), + typ: Number, + }, + "unknown-number-with-bound-refinements-float-exclusive": { + hex: "c71b0c8301c20392cb3ff3ae147ae147aec20492cb4016ae147ae147aec2", + value: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1.23), false), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5.67), false), + }), + typ: Number, + }, + "unknown-list-with-bound-refinements": { + hex: "c7070c8301c205010605", + value: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(1), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(5), + }), + typ: List{ElementType: String}, + }, + "unknown-map-with-bound-refinements": { + hex: "c7070c8301c205000604", + value: NewValue(Map{ElementType: Number}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(0), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(4), + }), + typ: Map{ElementType: Number}, + }, + "unknown-set-with-bound-refinements": { + hex: "c7070c8301c205020606", + value: NewValue(Set{ElementType: Bool}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(2), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(6), + }), + typ: Set{ElementType: Bool}, + }, } for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { t.Parallel() - got, err := test.value.MarshalMsgPack(test.typ) //nolint:staticcheck + got, err := test.value.MarshalMsgPack(test.typ) if err != nil { t.Fatalf("unexpected error marshaling: %s", err) } @@ -550,6 +667,7 @@ func TestValueFromMsgPack(t *testing.T) { if err != nil { t.Fatalf("unexpected error parsing hex: %s", err) } + val, err := ValueFromMsgPack(b, test.typ) if err != nil { t.Fatalf("unexpected error unmarshaling: %s", err) @@ -561,3 +679,210 @@ func TestValueFromMsgPack(t *testing.T) { }) } } +func TestValueFromMsgPack_refinements(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + hex string + expectedValue Value + typ Type + }{ + "unsupported-refinement-on-bool": { + // This hex value encodes the Nullness refinement as Key(100), which doesn't exist and should be ignored. + hex: "c7030c8164c2", + expectedValue: NewValue(Bool, UnknownValue), + typ: Bool, + }, + "unsupported-refinement-on-prefixed-string": { + // This hex value encodes the Nullness refinement as Key(100), which doesn't exist and should be ignored. + // The hex value also includes a valid string prefix which should be preserved. + hex: "c70e0c8264c202a97072656669783a2f2f", + expectedValue: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyStringPrefix: refinement.NewStringPrefix("prefix://"), + }), + typ: String, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, err := hex.DecodeString(test.hex) + if err != nil { + t.Fatalf("unexpected error parsing hex: %s", err) + } + + val, err := ValueFromMsgPack(b, test.typ) + if err != nil { + t.Fatalf("unexpected error unmarshaling: %s", err) + } + + if test.expectedValue.String() != val.String() { + t.Errorf("Unexpected results (-wanted +got): %s", cmp.Diff(test.expectedValue, val)) + } + }) + } +} + +// This test covers certain scenarios where we ignore refinement data during marshalling that are either invalid or not needed. +func TestMarshalMsgPack_refinements(t *testing.T) { + t.Parallel() + + // Hex encoding of the long prefix refinements that are eventually truncated in this test + longPrefixRefinement := "c801050c8201c202d9ff7072656669783a2f2f303132333435363738392d303132333435363738392d303132333435363738392d30313" + + "2333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d30313" + + "2333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d30313" + + "2333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d303132333435363738392d30313" + + "2333435363738392d30313233" + + tests := map[string]struct { + value Value + typ Type + expectedHex string + }{ + "unknown-dynamic-refinements-ignored": { + expectedHex: "d40000", + value: NewValue(DynamicPseudoType, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + typ: DynamicPseudoType, + }, + "unknown-string-with-empty-prefix-refinement": { + expectedHex: "c7030c8101c2", + value: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix(""), + }), + typ: String, + }, + "unknown-string-with-long-prefix-refinement-one": { + // This prefix will be cutoff at 256 bytes, so it will be equal to the other long prefix test. + expectedHex: longPrefixRefinement, + value: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix( + "prefix://0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-" + + "0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-" + + "0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-thiswillbecutoff1", + ), + }), + typ: String, + }, + "unknown-string-with-long-prefix-refinement-two": { + // This prefix will be cutoff at 256 bytes, so it will be equal to the other long prefix test. + expectedHex: longPrefixRefinement, + value: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix( + "prefix://0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-" + + "0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-" + + "0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-thiswillbecutoff2", + ), + }), + typ: String, + }, + "unknown-with-invalid-refinement-type": { + expectedHex: "d40000", + value: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + // This refinement will be ignored since only strings will attempt to encode this + refinement.KeyStringPrefix: refinement.NewStringPrefix("ignored"), + }), + typ: Bool, + }, + "unknown-with-invalid-refinement-data": { + expectedHex: "d40000", + value: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + // This refinement will be ignored since we don't know how to encode it + refinement.Key(100): refinement.NewStringPrefix("ignored"), + }), + typ: Bool, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + got, err := test.value.MarshalMsgPack(test.typ) + if err != nil { + t.Fatalf("unexpected error marshaling: %s", err) + } + res := hex.EncodeToString(got) + if res != test.expectedHex { + t.Errorf("expected msgpack to be %q, got %q", test.expectedHex, res) + } + }) + } +} + +func TestMarshalMsgPack_error(t *testing.T) { + t.Parallel() + tests := map[string]struct { + value Value + typ Type + expectedError error + }{ + "unknown-with-invalid-nullness-refinement": { + value: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + // String prefix is invalid on KeyNullness + refinement.KeyNullness: refinement.NewStringPrefix("invalid"), + }), + typ: String, + expectedError: errors.New("error encoding Nullness value refinement: unexpected refinement data of type refinement.StringPrefix"), + }, + "unknown-with-invalid-prefix-refinement": { + value: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + // Nullness is invalid on KeyStringPrefix + refinement.KeyStringPrefix: refinement.NewNullness(false), + }), + typ: String, + expectedError: errors.New("error encoding StringPrefix value refinement: unexpected refinement data of type refinement.Nullness"), + }, + "unknown-with-invalid-number-lowerbound-refinement": { + value: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + // NumberUpperBound is invalid on KeyNumberLowerBound + refinement.KeyNumberLowerBound: refinement.NewNumberUpperBound(big.NewFloat(1), true), + }), + typ: Number, + expectedError: errors.New("error encoding NumberLowerBound value refinement: unexpected refinement data of type refinement.NumberUpperBound"), + }, + "unknown-with-invalid-number-upperbound-refinement": { + value: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + // NumberLowerBound is invalid on KeyNumberUpperBound + refinement.KeyNumberUpperBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + }), + typ: Number, + expectedError: errors.New("error encoding NumberUpperBound value refinement: unexpected refinement data of type refinement.NumberLowerBound"), + }, + "unknown-with-invalid-collection-lowerbound-refinement": { + value: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + // CollectionLengthUpperBound is invalid on KeyCollectionLengthLowerBound + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthUpperBound(1), + }), + typ: List{ElementType: String}, + expectedError: errors.New("error encoding CollectionLengthLowerBound value refinement: unexpected refinement data of type refinement.CollectionLengthUpperBound"), + }, + "unknown-with-invalid-collection-upperbound-refinement": { + value: NewValue(Map{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + // CollectionLengthLowerBound is invalid on KeyCollectionLengthUpperBound + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthLowerBound(1), + }), + typ: Map{ElementType: String}, + expectedError: errors.New("error encoding CollectionLengthUpperBound value refinement: unexpected refinement data of type refinement.CollectionLengthLowerBound"), + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := test.value.MarshalMsgPack(test.typ) + if err == nil { + t.Fatalf("got no error, wanted err: %s", test.expectedError) + } + + if !strings.Contains(err.Error(), test.expectedError.Error()) { + t.Fatalf("wanted error %q, got error: %s", test.expectedError.Error(), err.Error()) + } + }) + } +} diff --git a/tftypes/value_test.go b/tftypes/value_test.go index f13d0c5b..a55896bd 100644 --- a/tftypes/value_test.go +++ b/tftypes/value_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func numberComparer(i, j *big.Float) bool { @@ -783,11 +784,187 @@ func TestValueEqual(t *testing.T) { val2: NewValue(String, UnknownValue), equal: true, }, + "unknownEqual-empty-refinements": { + val1: NewValue(Bool, UnknownValue), + val2: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{}), + equal: true, + }, + "unknownEqual-bool-refinements": { + val1: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + val2: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + equal: true, + }, + "unknownEqual-string-refinements": { + val1: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix("hello"), + }), + val2: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix("hello"), + }), + equal: true, + }, + "unknownEqual-number-refinements": { + val1: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), false), + }), + val2: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), false), + }), + equal: true, + }, + "unknownEqual-number-refinements-float": { + val1: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1.23), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5.67), false), + }), + val2: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1.23), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5.67), false), + }), + equal: true, + }, + "unknownEqual-list-refinements": { + val1: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(1), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(5), + }), + val2: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(1), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(5), + }), + equal: true, + }, "unknownDiff": { val1: NewValue(String, UnknownValue), val2: NewValue(String, "world"), equal: false, }, + "unknownDiff-nil-refinements": { + val1: NewValue(Bool, UnknownValue), + val2: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(true), + }), + equal: false, + }, + "unknownDiff-empty-refinements": { + val1: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(true), + }), + val2: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{}), + equal: false, + }, + "unknownDiff-bool-refinements": { + val1: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + val2: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(true), + }), + equal: false, + }, + "unknownDiff-string-refinements": { + val1: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix("hello"), + }), + val2: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix("world"), + }), + equal: false, + }, + "unknownDiff-number-refinements-lowerInclusiveDiff": { + val1: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), true), + }), + val2: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), false), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), true), + }), + equal: false, + }, + "unknownDiff-number-refinements-upperInclusiveDiff": { + val1: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), true), + }), + val2: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), false), + }), + equal: false, + }, + "unknownDiff-number-refinements-lowerBoundDiff": { + val1: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), true), + }), + val2: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(3), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), true), + }), + equal: false, + }, + "unknownDiff-number-refinements-upperBoundDiff": { + val1: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(5), true), + }), + val2: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(1), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(3), true), + }), + equal: false, + }, + "unknownDiff-list-refinements-lowerBoundDiff": { + val1: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(1), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(5), + }), + val2: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(3), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(5), + }), + equal: false, + }, + "unknownDiff-list-refinements-upperBoundDiff": { + val1: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(1), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(5), + }), + val2: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(1), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(3), + }), + equal: false, + }, "listEqual": { val1: NewValue(List{ElementType: String}, []Value{ NewValue(String, "hello"), @@ -1721,6 +1898,59 @@ func TestValueString(t *testing.T) { in: Value{}, expected: "invalid typeless tftypes.Value<>", }, + "unknown-bool": { + in: NewValue(Bool, UnknownValue), + expected: "tftypes.Bool", + }, + "unknown-bool-with-nullness-refinement": { + in: NewValue(Bool, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + }), + expected: `tftypes.Bool`, + }, + "unknown-string": { + in: NewValue(String, UnknownValue), + expected: "tftypes.String", + }, + "unknown-string-with-multiple-refinements": { + in: NewValue(String, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyStringPrefix: refinement.NewStringPrefix("str://"), + }), + expected: `tftypes.String`, + }, + "unknown-number": { + in: NewValue(Number, UnknownValue), + expected: "tftypes.Number", + }, + "unknown-number-with-multiple-refinements": { + in: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(5), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(10), true), + }), + expected: `tftypes.Number`, + }, + "unknown-number-float-with-multiple-refinements": { + in: NewValue(Number, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyNumberLowerBound: refinement.NewNumberLowerBound(big.NewFloat(5.67), true), + refinement.KeyNumberUpperBound: refinement.NewNumberUpperBound(big.NewFloat(10.123), true), + }), + expected: `tftypes.Number`, + }, + "unknown-list": { + in: NewValue(List{ElementType: String}, UnknownValue), + expected: "tftypes.List[tftypes.String]", + }, + "unknown-list-with-multiple-refinements": { + in: NewValue(List{ElementType: String}, UnknownValue).Refine(refinement.Refinements{ + refinement.KeyNullness: refinement.NewNullness(false), + refinement.KeyCollectionLengthLowerBound: refinement.NewCollectionLengthLowerBound(2), + refinement.KeyCollectionLengthUpperBound: refinement.NewCollectionLengthUpperBound(7), + }), + expected: `tftypes.List[tftypes.String]`, + }, "string": { in: NewValue(String, "hello"), expected: "tftypes.String<\"hello\">", @@ -1875,3 +2105,31 @@ func TestValueString(t *testing.T) { }) } } + +func TestValueRefine_immutable(t *testing.T) { + t.Parallel() + + originalRefinements := refinement.Refinements{refinement.KeyStringPrefix: refinement.NewStringPrefix("hello")} + originalVal := NewValue(String, UnknownValue).Refine(originalRefinements) + + // Attempt to mutate the original refinements map + originalRefinements[refinement.KeyStringPrefix] = refinement.NewStringPrefix("world") + + if !originalVal.Equal(NewValue(String, UnknownValue).Refine(refinement.Refinements{refinement.KeyStringPrefix: refinement.NewStringPrefix("hello")})) { + t.Fatal("unexpected Refinements mutation") + } +} + +func TestValueRefinements_immutable(t *testing.T) { + t.Parallel() + + originalVal := NewValue(String, UnknownValue).Refine(refinement.Refinements{refinement.KeyStringPrefix: refinement.NewStringPrefix("hello")}) + originalRefinements := originalVal.Refinements() + + // Attempt to mutate the original refinements map + originalRefinements[refinement.KeyStringPrefix] = refinement.NewStringPrefix("world") + + if !originalVal.Equal(NewValue(String, UnknownValue).Refine(refinement.Refinements{refinement.KeyStringPrefix: refinement.NewStringPrefix("hello")})) { + t.Fatal("unexpected Refinements mutation") + } +}