From c09711761d855c6a3301a7c9cc5a3fd33d318cb3 Mon Sep 17 00:00:00 2001 From: ShocOne <62835948+ShocOne@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:54:38 +0100 Subject: [PATCH] feat: enforce the document's declared bounds at plan time An API that silently clamps or truncates an out-of-range value leaves state and config disagreeing with no error to explain it. The bounds the document declares now become validators, so the configuration fails at plan time instead. Length is measured in characters: JSON Schema counts maxLength in characters and the framework's LengthBetween counts bytes, which would refuse a valid value the moment it stopped being ASCII. A pattern is compiled at generation time and dropped when Go cannot take it. OpenAPI patterns are ECMA-262 and Go's regexp is RE2, which has no lookahead; an expression RE2 rejects would panic the generated provider inside MustCompile at package initialisation, where go build cannot see it. A fractional bound on an integer attribute is dropped rather than truncated, which would silently move the boundary. Co-Authored-By: Claude Opus 5 (1M context) --- internal/emit/render_constraints.go | 175 +++++++++++++++++++++++ internal/emit/render_constraints_test.go | 157 ++++++++++++++++++++ internal/emit/render_schema.go | 7 +- 3 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 internal/emit/render_constraints.go create mode 100644 internal/emit/render_constraints_test.go diff --git a/internal/emit/render_constraints.go b/internal/emit/render_constraints.go new file mode 100644 index 0000000..51c8cd0 --- /dev/null +++ b/internal/emit/render_constraints.go @@ -0,0 +1,175 @@ +// The plan-time enforcement of the bounds a document declares. An API that +// silently clamps or truncates an out-of-range value leaves state and config +// disagreeing with no error to explain it; a validator turns that into a plan +// failure naming the attribute. + +package emit + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/code" + ir "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/intermediate_representation" +) + +// validatorRoot is where the stock validator packages live. +const validatorPackageRoot = "github.com/hashicorp/terraform-plugin-framework-validators/" + +// constraintValidators is every validator that follows from what the document +// declares about one attribute's value: its length, its size, its numeric +// range, and the pattern it must match. +// +// Nothing here registers an import; each expression carries the packages it +// references, and validatorLines is the single place those are honoured. +func constraintValidators(n node) []code.CustomValidator { + kind := n.attr.Kind + switch { + case kind == ir.TypeList || kind == ir.TypeMap: + // A size bound applies to the collection whether its elements are + // scalars or objects, so this is deliberately not gated on Nested. + return sizeValidators(n) + case n.attr.Nested != nil: + // An object declaring a length or a range is declaring it about + // something this attribute does not hold. + return nil + case kind == ir.TypeString: + return stringValidators(n) + case kind == ir.TypeInt64 || kind == ir.TypeFloat64: + return numericValidators(n) + } + return nil +} + +// stringValidators are the length bounds and the pattern. +// +// Length is measured in characters rather than bytes: JSON Schema counts +// maxLength in characters, and the framework's LengthBetween counts bytes, +// which would refuse a valid value the moment it stopped being ASCII. +func stringValidators(n node) []code.CustomValidator { + var out []code.CustomValidator + + if call, ok := boundCall("UTF8Length", "", n.attr.MinLength, n.attr.MaxLength); ok { + out = append(out, stockValidator("stringvalidator", call)) + } + if pattern := n.attr.Pattern; pattern != "" { + if expression, ok := regexLiteral(pattern); ok { + out = append(out, code.CustomValidator{ + Imports: []code.Import{ + {Path: validatorPackageRoot + "stringvalidator"}, + {Path: "regexp"}, + }, + SchemaDefinition: fmt.Sprintf("stringvalidator.RegexMatches(regexp.MustCompile(%s), %s)", + expression, strconv.Quote("must match "+pattern)), + }) + } + } + return out +} + +// numericValidators is the declared range, rendered against the attribute's +// own type so the literal the validator takes is the one it compares with. +func numericValidators(n node) []code.CustomValidator { + pkg := "float64validator" + format := func(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } + minimum, maximum := n.attr.Minimum, n.attr.Maximum + + if n.attr.Kind == ir.TypeInt64 { + pkg = "int64validator" + format = func(v float64) string { return strconv.FormatInt(int64(v), 10) } + // A fractional bound on an integer attribute describes a value the + // attribute cannot hold. Truncating it would silently move the + // boundary, so the bound is dropped and the other one still stands. + minimum, maximum = integralOnly(minimum), integralOnly(maximum) + } + + var out []code.CustomValidator + if call, ok := boundCall("", "", formatted(minimum, format), formatted(maximum, format)); ok { + out = append(out, stockValidator(pkg, call)) + } + return out +} + +// sizeValidators is the declared member-count range of a list or a map. +func sizeValidators(n node) []code.CustomValidator { + pkg := "listvalidator" + if n.attr.Kind == ir.TypeMap { + pkg = "mapvalidator" + } + if call, ok := boundCall("Size", "Size", n.attr.MinItems, n.attr.MaxItems); ok { + return []code.CustomValidator{stockValidator(pkg, call)} + } + return nil +} + +// boundCall renders the one call that states a pair of bounds: Between when +// both are declared, AtLeast or AtMost when one is. prefix names the family +// ("UTF8Length", "Size", or none for a plain range); betweenPrefix is the +// same for the two-sided spelling, because a size range is SizeBetween while +// a length range is UTF8LengthBetween and a numeric range is plain Between. +// +// The generic parameter is the literal's own type, so an int64 bound renders +// without a decimal point and a float64 bound keeps one. +func boundCall[T int64 | string](prefix, betweenPrefix string, minimum, maximum *T) (string, bool) { + if betweenPrefix == "" { + betweenPrefix = prefix + } + switch { + case minimum != nil && maximum != nil: + return fmt.Sprintf("%sBetween(%v, %v)", betweenPrefix, *minimum, *maximum), true + case minimum != nil: + return fmt.Sprintf("%sAtLeast(%v)", prefix, *minimum), true + case maximum != nil: + return fmt.Sprintf("%sAtMost(%v)", prefix, *maximum), true + } + return "", false +} + +// stockValidator is one validator from a stock package, carrying that +// package as its only import. +func stockValidator(pkg, call string) code.CustomValidator { + return code.CustomValidator{ + Imports: []code.Import{{Path: validatorPackageRoot + pkg}}, + SchemaDefinition: pkg + "." + call, + } +} + +// integralOnly passes a bound through only when it names a whole number. +func integralOnly(bound *float64) *float64 { + if bound == nil || *bound != float64(int64(*bound)) { + return nil + } + return bound +} + +// formatted renders a numeric bound as the literal its validator takes. +func formatted(bound *float64, format func(float64) string) *string { + if bound == nil { + return nil + } + literal := format(*bound) + return &literal +} + +// regexLiteral renders a declared pattern as a Go regexp literal, and reports +// whether Go can compile it at all. +// +// OpenAPI patterns are ECMA-262 and Go's regexp is RE2, which has no +// lookahead and no backreferences. An expression RE2 rejects would panic the +// generated provider inside MustCompile at package initialisation — before +// any test or plan runs, and where `go build` cannot see it — so one that +// does not compile here yields no validator at all. +func regexLiteral(pattern string) (string, bool) { + if _, err := regexp.Compile(pattern); err != nil { + return "", false + } + // A raw literal keeps a pattern's backslashes as written. It cannot hold + // a backtick or a carriage return, and an interpreted literal can hold + // either. + if !strings.ContainsAny(pattern, "`\r") { + return "`" + pattern + "`", true + } + return strconv.Quote(pattern), true +} diff --git a/internal/emit/render_constraints_test.go b/internal/emit/render_constraints_test.go new file mode 100644 index 0000000..0923f3c --- /dev/null +++ b/internal/emit/render_constraints_test.go @@ -0,0 +1,157 @@ +package emit + +import ( + "strings" + "testing" + + ir "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/intermediate_representation" +) + +func i64(v int64) *int64 { return &v } +func f64(v float64) *float64 { return &v } +func constraintDecl(attr ir.Attribute) string { + return declOf(schemaResource, attr) +} + +// TestUnit_ConstraintValidators_RenderTheDeclaredBounds proves each declared +// bound becomes the validator that enforces it at plan time, in the spelling +// its own package uses. +func TestUnit_ConstraintValidators_RenderTheDeclaredBounds(t *testing.T) { + for _, testCase := range []struct { + name string + attr ir.Attribute + want string + }{ + { + "a string with both length bounds", + ir.Attribute{Name: "n", Kind: ir.TypeString, MinLength: i64(8), MaxLength: i64(64)}, + "stringvalidator.UTF8LengthBetween(8, 64)", + }, + { + "a string with only a minimum length", + ir.Attribute{Name: "n", Kind: ir.TypeString, MinLength: i64(8)}, + "stringvalidator.UTF8LengthAtLeast(8)", + }, + { + "a string with only a maximum length", + ir.Attribute{Name: "n", Kind: ir.TypeString, MaxLength: i64(64)}, + "stringvalidator.UTF8LengthAtMost(64)", + }, + { + "an integer range", + ir.Attribute{Name: "n", Kind: ir.TypeInt64, Minimum: f64(1), Maximum: f64(10)}, + "int64validator.Between(1, 10)", + }, + { + "an integer with only a maximum", + ir.Attribute{Name: "n", Kind: ir.TypeInt64, Maximum: f64(10)}, + "int64validator.AtMost(10)", + }, + { + "a float range keeps its fraction", + ir.Attribute{Name: "n", Kind: ir.TypeFloat64, Minimum: f64(0.5), Maximum: f64(99.5)}, + "float64validator.Between(0.5, 99.5)", + }, + { + "a list size range", + ir.Attribute{Name: "n", Kind: ir.TypeList, ElementType: ir.TypeString, MinItems: i64(1), MaxItems: i64(5)}, + "listvalidator.SizeBetween(1, 5)", + }, + { + "a map size floor", + ir.Attribute{Name: "n", Kind: ir.TypeMap, ElementType: ir.TypeString, MinItems: i64(1)}, + "mapvalidator.SizeAtLeast(1)", + }, + { + "a pattern becomes a compiled match", + ir.Attribute{Name: "n", Kind: ir.TypeString, Pattern: `^[a-z]+$`}, + "stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z]+$`), \"must match ^[a-z]+$\")", + }, + } { + if decl := constraintDecl(testCase.attr); !strings.Contains(decl, testCase.want) { + t.Errorf("%s: does not carry %q:\n%s", testCase.name, testCase.want, decl) + } + } +} + +// TestUnit_ConstraintValidators_SkipAPatternRE2CannotCompile proves a +// lookahead — legal in ECMA-262, rejected by RE2 — yields no validator at +// all. Emitting it would panic the generated provider inside MustCompile at +// package initialisation, where go build cannot see it. +func TestUnit_ConstraintValidators_SkipAPatternRE2CannotCompile(t *testing.T) { + decl := constraintDecl(ir.Attribute{ + Name: "n", Kind: ir.TypeString, Pattern: `^(?=.*[A-Z]).{8,}$`, + }) + if strings.Contains(decl, "RegexMatches") { + t.Errorf("a pattern RE2 cannot compile was emitted anyway:\n%s", decl) + } + + // The rest of the attribute still stands: one unrenderable pattern is a + // fact about the pattern, not about the length bound beside it. + both := constraintDecl(ir.Attribute{ + Name: "n", Kind: ir.TypeString, Pattern: `^(?=.*[A-Z]).{8,}$`, MaxLength: i64(64), + }) + if !strings.Contains(both, "stringvalidator.UTF8LengthAtMost(64)") { + t.Errorf("an unrenderable pattern took the length bound with it:\n%s", both) + } +} + +// TestUnit_ConstraintValidators_DropAFractionalBoundOnAnInteger proves a +// bound the attribute's type cannot hold is dropped rather than truncated, +// which would silently move the boundary. +func TestUnit_ConstraintValidators_DropAFractionalBoundOnAnInteger(t *testing.T) { + decl := constraintDecl(ir.Attribute{ + Name: "n", Kind: ir.TypeInt64, Minimum: f64(1.5), Maximum: f64(10), + }) + if strings.Contains(decl, "1.5") || strings.Contains(decl, "Between") { + t.Errorf("a fractional minimum survived onto an integer attribute:\n%s", decl) + } + if !strings.Contains(decl, "int64validator.AtMost(10)") { + t.Errorf("the integral bound beside it was dropped too:\n%s", decl) + } +} + +// TestUnit_ConstraintValidators_LeaveANestedObjectAlone proves a length or a +// range declared on an object is not applied to the object: it describes +// something the attribute does not hold. A size bound on a nested list still +// applies, because the list is the thing being sized. +func TestUnit_ConstraintValidators_LeaveANestedObjectAlone(t *testing.T) { + nested := &ir.AttributeTree{Attributes: []ir.Attribute{ + {Name: "inner", Kind: ir.TypeString, ComputedOptionalRequired: ir.Optional}, + }} + + object := constraintDecl(ir.Attribute{ + Name: "n", Kind: ir.TypeObject, Nested: nested, MaxLength: i64(64), Maximum: f64(10), + }) + if strings.Contains(object, "Validators:") { + t.Errorf("a bound was applied to an object:\n%s", object) + } + + list := constraintDecl(ir.Attribute{ + Name: "n", Kind: ir.TypeList, Nested: nested, MaxItems: i64(5), + }) + if !strings.Contains(list, "listvalidator.SizeAtMost(5)") { + t.Errorf("a nested list was not sized:\n%s", list) + } +} + +// TestUnit_ConstraintValidators_DeclareTheirOwnImports proves every rendered +// expression registered the package it names, so a validator can never reach +// a file whose import block forgot it. +func TestUnit_ConstraintValidators_DeclareTheirOwnImports(t *testing.T) { + sb := &schemaBuilder{kind: schemaResource, imports: newImportSet("example.com/m")} + sb.attributeDecl(node{attr: ir.Attribute{ + Name: "n", Kind: ir.TypeString, MaxLength: i64(64), Pattern: `^[a-z]+$`, + }}, 0) + + rendered := sb.imports.render() + for _, want := range []string{ + "terraform-plugin-framework-validators/stringvalidator", + `"regexp"`, + "terraform-plugin-framework/schema/validator", + } { + if !strings.Contains(rendered, want) { + t.Errorf("the import block does not declare %q:\n%s", want, rendered) + } + } +} diff --git a/internal/emit/render_schema.go b/internal/emit/render_schema.go index 9b4cc0d..935fea2 100644 --- a/internal/emit/render_schema.go +++ b/internal/emit/render_schema.go @@ -136,8 +136,9 @@ func (sb *schemaBuilder) attributeDecl(n node, depth int) string { // validators is every stock validator one attribute carries, each a // finished expression travelling with the imports it needs: the enum OneOf -// on a closed-set string, and the AlsoRequires that realizes a dependency -// whose subject is this root attribute. +// on a closed-set string, the bounds the document declares about the value, +// and the AlsoRequires that realizes a dependency whose subject is this root +// attribute. // // Nothing here registers an import. An expression that needs a package says // so on the value it returns, and validatorLines is the single place those @@ -159,6 +160,8 @@ func (sb *schemaBuilder) validators(n node, depth int) []code.CustomValidator { }) } + validators = append(validators, constraintValidators(n)...) + if depth == sb.rootDepth { if reqs, ok := sb.deps[n.attr.Name]; ok { schema := schemaTypeOf(n)