diff --git a/internal/configs/checks.go b/internal/configs/checks.go index 2e5263c4c9..d4b1250ee8 100644 --- a/internal/configs/checks.go +++ b/internal/configs/checks.go @@ -80,14 +80,14 @@ func (cr *CheckRule) validateSelfReferences(checkType string, addr addrs.Resourc return diags } -// DecodeCheckRuleBlock decodes the contents of the given block as a check rule. +// decodeCheckRuleBlock decodes the contents of the given block as a check rule. // // Unlike most of our "decode..." functions, this one can be applied to blocks // of various types as long as their body structures are "check-shaped". The // function takes the containing block only because some error messages will // refer to its location, and the returned object's DeclRange will be the // block's header. -func DecodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) { +func decodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) { var diags hcl.Diagnostics cr := &CheckRule{ DeclRange: block.DefRange, @@ -230,7 +230,7 @@ func decodeCheckBlock(block *hcl.Block, override bool) (*Check, hcl.Diagnostics) check.DataResource = data } case "assert": - assert, moreDiags := DecodeCheckRuleBlock(block, override) + assert, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) if !moreDiags.HasErrors() { check.Asserts = append(check.Asserts, assert) diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index d916dc4ed5..bd5dedec63 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -212,7 +212,7 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno switch block.Type { case "validation": - vv, moreDiags := DecodeCheckRuleBlock(block, override) + vv, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) diags = append(diags, checkVariableValidationBlock(v.Name, vv)...) @@ -443,7 +443,7 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic for _, block := range content.Blocks { switch block.Type { case "precondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) o.Preconditions = append(o.Preconditions, cr) case "postcondition": diff --git a/internal/configs/resource.go b/internal/configs/resource.go index a8fd88e3de..8f4276a61b 100644 --- a/internal/configs/resource.go +++ b/internal/configs/resource.go @@ -285,7 +285,7 @@ func decodeResourceBlock(block *hcl.Block, override bool, allowExperiments bool) for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) @@ -497,7 +497,7 @@ func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagn for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) @@ -673,7 +673,7 @@ func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Di for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) diff --git a/internal/configs/test_file.go b/internal/configs/test_file.go index d378008109..2456bf8933 100644 --- a/internal/configs/test_file.go +++ b/internal/configs/test_file.go @@ -697,7 +697,7 @@ func decodeTestRunBlock(block *hcl.Block, file *TestFile, experimentsAllowed boo for _, block := range content.Blocks { switch block.Type { case "assert": - cr, crDiags := DecodeCheckRuleBlock(block, false) + cr, crDiags := decodeCheckRuleBlock(block, false) diags = append(diags, crDiags...) if !crDiags.HasErrors() { r.CheckRules = append(r.CheckRules, cr) diff --git a/internal/stacks/stackconfig/checks.go b/internal/stacks/stackconfig/checks.go new file mode 100644 index 0000000000..e4afb6dc1d --- /dev/null +++ b/internal/stacks/stackconfig/checks.go @@ -0,0 +1,72 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackconfig + +import "github.com/hashicorp/hcl/v2" + +// CheckRule represents a custom validation rule for a stack input variable. +// +// This is the stacks-specific equivalent of configs.CheckRule in the core +// Terraform package. It is intentionally duplicated here to maintain +// separation between stacks and core Terraform, allowing each to evolve +// independently. +type CheckRule struct { + // Condition is an expression that must evaluate to true if the validation + // passes, or false if it fails. The expression may only refer to the + // variable being validated (via var.). + Condition hcl.Expression + + // ErrorMessage is an expression that evaluates to the error message shown + // to the user when the condition is false. It must evaluate to a string. + ErrorMessage hcl.Expression + + DeclRange hcl.Range +} + +// decodeCheckRuleBlock decodes a validation block for stack input variables. +// This is duplicated from the core configs package to maintain separation between +// stacks and core Terraform, allowing each to evolve independently. +func decodeCheckRuleBlock(block *hcl.Block) (*CheckRule, hcl.Diagnostics) { + var diags hcl.Diagnostics + cr := &CheckRule{ + DeclRange: block.DefRange, + } + + content, hclDiags := block.Body.Content(checkRuleBlockSchema) + diags = append(diags, hclDiags...) + + if attr, exists := content.Attributes["condition"]; exists { + cr.Condition = attr.Expr + + if len(cr.Condition.Variables()) == 0 { + // A condition expression that doesn't refer to any variable is + // pointless, because its result would always be a constant. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid validation expression", + Detail: "The condition expression must refer to at least one object from elsewhere in the configuration, or else its result would not be checking anything.", + Subject: cr.Condition.Range().Ptr(), + }) + } + } + + if attr, exists := content.Attributes["error_message"]; exists { + cr.ErrorMessage = attr.Expr + } + + return cr, diags +} + +var checkRuleBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "condition", + Required: true, + }, + { + Name: "error_message", + Required: true, + }, + }, +} diff --git a/internal/stacks/stackconfig/input_variable.go b/internal/stacks/stackconfig/input_variable.go index ebd27feacf..6f8fbb7a72 100644 --- a/internal/stacks/stackconfig/input_variable.go +++ b/internal/stacks/stackconfig/input_variable.go @@ -7,7 +7,6 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/stacks/stackconfig/typeexpr" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" @@ -29,7 +28,7 @@ type InputVariable struct { // that provided values meet the specified constraints. // Each CheckRule includes a condition expression that must evaluate to true, // and an error message to display if the validation fails. - Validations []*configs.CheckRule + Validations []*CheckRule DeclRange tfdiags.SourceRange } @@ -103,7 +102,7 @@ func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnos // Decode the validation block into a CheckRule structure. // This only validates the syntax and structure of the validation block itself, // not the actual runtime validation of input values. - vv, hclDiags := configs.DecodeCheckRuleBlock(block, false) + vv, hclDiags := decodeCheckRuleBlock(block) diags = diags.Append(hclDiags) // Only add the validation rule if it was successfully parsed. // If there were errors (e.g., missing condition or error_message), diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index 2d0be06b52..ebcdc5037f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -12,13 +12,13 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/objchange" "github.com/hashicorp/terraform/internal/promising" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackplan" "github.com/hashicorp/terraform/internal/stacks/stackstate" "github.com/hashicorp/terraform/internal/tfdiags" @@ -147,10 +147,7 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } - // First, apply any defaults that are declared in the - // configuration. - - // Next, convert the value to the expected type. + // Convert the value to the expected type. val, err = convert.Convert(val, wantTy) if err != nil { diags = diags.Append(&hcl.Diagnostic{ @@ -189,17 +186,20 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } + // Mark the value before validation so that validation can detect + // sensitive/ephemeral marks and avoid leaking protected values in error messages. + val = cfg.markValue(val) + // Evaluate custom validation rules against the input value. - // Validation is skipped during ValidatePhase because: - // 1. Input variable values are not available during validate (only during plan/apply) - // 2. Validation conditions may reference resources or other runtime values + // Validation is skipped during ValidatePhase because actual input values + // are not yet available at that phase — only plan/apply provide them. // This matches the behavior of core Terraform's variable validation. if phase != ValidatePhase { moreDiags := v.evalVariableValidations(ctx, val, phase) diags = diags.Append(moreDiags) } - return cfg.markValue(val), diags + return val, diags default: definedByCallInst, definedByRemovedCallInst := v.DefinedByStackCallInstance(ctx, phase) @@ -208,6 +208,9 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va allVals := definedByCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) + // Evaluate custom validation rules for values from stack call instances. // Skip during ValidatePhase as values are not yet available. if phase != ValidatePhase { @@ -215,19 +218,22 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va diags = diags.Append(moreDiags) } - return cfg.markValue(val), diags + return val, diags case definedByRemovedCallInst != nil: allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // Evaluate validation rules even for removed stack instances. + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) + + // Evaluate validation rules for removed stack instances. // Skip during ValidatePhase as values are not yet available. if phase != ValidatePhase { moreDiags := v.evalVariableValidations(ctx, val, phase) diags = diags.Append(moreDiags) } - return cfg.markValue(val), diags + return val, diags default: // We seem to belong to a call instance that doesn't actually // exist in the configuration. That either means that @@ -391,9 +397,11 @@ func (v *InputVariable) tracingName() string { // during config loading; this function evaluates those rules against actual input values. // // The validation process: -// 1. Creates an HCL evaluation context with the variable's value and available functions -// 2. Evaluates each validation rule's condition expression -// 3. If the condition returns false, evaluates the error_message and reports a diagnostic +// 1. Creates an HCL evaluation context with the variable's value and available functions +// 2. Evaluates each validation rule's condition and error_message expressions +// 3. Always validates the error_message structure (sensitive/ephemeral marks are flagged +// regardless of whether the condition passes or fails) +// 4. If the condition is false, reports an "Invalid value for variable" diagnostic // // This follows the same approach as core Terraform's evalVariableValidations, including // handling of sensitive values, unknown values, and error message evaluation. @@ -406,25 +414,29 @@ func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Val return diags } - // Get the available functions from the stack scope. - // This allows validation conditions to use built-in functions like length(), regex(), etc. + // Get provider-defined functions from the stack scope. + // These will be combined with built-in functions below. functions, moreDiags := v.stack.ExternalFunctions(ctx) diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // If we can't get the function table, we can't evaluate validation expressions + // that depend on functions. Return early to avoid confusing downstream errors. + return diags + } - // Create a scope to get the function table. - // We don't need a full evaluation context, just the functions. + // Create a scope to get the complete function table (provider-defined + built-in). + // fakeScope.Functions() will combine the provider functions with built-in functions + // like length(), regex(), etc. We don't need a full evaluation context, just the functions. fakeScope := &lang.Scope{ Data: nil, // not a real scope; can't actually make an evalcontext BaseDir: ".", PureOnly: phase != ApplyPhase, - ConsoleMode: false, PlanTimestamp: v.stack.PlanTimestamp(), ExternalFuncs: functions, } // Create an HCL evaluation context with the variable value and functions. // The variable is made available as var. within validation expressions. - // This mirrors how validation conditions are evaluated in core Terraform. hclCtx := &hcl.EvalContext{ Variables: map[string]cty.Value{ "var": cty.ObjectVal(map[string]cty.Value{ @@ -449,24 +461,28 @@ func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Val // This function handles the evaluation of one validation block's condition and error_message. // It follows the same logic as core Terraform's variable validation: // -// 1. Evaluates the condition expression -// 2. Handles unknown/null/invalid results appropriately -// 3. If condition is false, evaluates the error_message -// 4. Checks for sensitive/ephemeral values in error messages -// 5. Constructs a diagnostic with the error message and validation rule location +// 1. Evaluates the condition and error_message expressions up front +// 2. Handles unknown/null/invalid condition results appropriately +// 3. Always validates the error_message structure — sensitive/ephemeral marks +// in the message are flagged even when the condition passes +// 4. Returns early if the condition passes (after reporting any message issues) +// 5. Otherwise constructs an "Invalid value for variable" diagnostic // // Parameters: // - validation: The validation rule to evaluate (contains condition and error_message expressions) // - hclCtx: The HCL evaluation context with the variable value and functions // - valueRng: The source range of the variable declaration (for diagnostic reporting) -func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics { +func evalVariableValidation(validation *stackconfig.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics { const errInvalidCondition = "Invalid variable validation result" const errInvalidValue = "Invalid value for variable" var diags tfdiags.Diagnostics - // Evaluate the validation condition expression + // Evaluate both condition and error message up front. The error message is + // always inspected for structural problems (sensitive/ephemeral marks), not only + // when the condition fails. result, moreDiags := validation.Condition.Value(hclCtx) diags = diags.Append(moreDiags) + errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) if moreDiags.HasErrors() { // If we couldn't evaluate the condition at all (syntax error, etc.), @@ -495,7 +511,8 @@ func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalConte } // Convert result to boolean - result, err := convert.Convert(result, cty.Bool) + var err error + result, err = convert.Convert(result, cty.Bool) if err != nil { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -512,66 +529,91 @@ func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalConte // The marks don't affect the validation result, only how we handle the error message. result, _ = result.Unmark() - // If the condition evaluated to true, the validation passed. - if result.True() { - return diags - } - - // Validation failed - now evaluate the error_message to show to the user. - errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) + // Always process and validate the error_message expression, even when the condition + // passes — an invalid error message (sensitive, ephemeral, non-string, etc.) should + // be flagged regardless of whether the check succeeds or fails. diags = diags.Append(errorDiags) var errorMessage string - if !errorDiags.HasErrors() && errorValue.IsKnown() && !errorValue.IsNull() { - errorValue, err := convert.Convert(errorValue, cty.String) - if err != nil { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid error message", - Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)), - Subject: validation.ErrorMessage.Range().Ptr(), - Expression: validation.ErrorMessage, - EvalContext: hclCtx, - }) - errorMessage = "Failed to evaluate condition error message." - } else { - // Check for sensitive/ephemeral marks - if marks.Has(errorValue, marks.Sensitive) { + if !errorDiags.HasErrors() { + if !errorValue.IsKnown() { + if !result.True() { + // An unknown error message is only a problem when the condition actually + // fails, since we need to display it to the user. diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Error message refers to sensitive values", - Detail: "The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message.", - Subject: validation.ErrorMessage.Range().Ptr(), + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: "Unsuitable value for error message: expression refers to values that won't be known until the apply phase.", + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, }) - errorMessage = "The error message included a sensitive value, so it will not be displayed." - } else if marks.Has(errorValue, marks.Ephemeral) { + return diags + } + } else if !errorValue.IsNull() { + errorValue, err = convert.Convert(errorValue, cty.String) + if err != nil { diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Error message refers to ephemeral values", - Detail: "The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message.", - Subject: validation.ErrorMessage.Range().Ptr(), + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)), + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, }) - errorMessage = "The error message included an ephemeral value, so it will not be displayed." } else { - errorMessage = strings.TrimSpace(errorValue.AsString()) + // Check for sensitive/ephemeral marks; these are flagged even when + // the condition passes, since the error message is structurally invalid. + if marks.Has(errorValue, marks.Sensitive) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to sensitive values", + Detail: `The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message. + +You can correct this by removing references to sensitive values, or by carefully using the nonsensitive() function if the expression will not reveal the sensitive data.`, + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + errorMessage = "The error message included a sensitive value, so it will not be displayed." + } else if marks.Has(errorValue, marks.Ephemeral) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to ephemeral values", + Detail: `The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message. + +You can correct this by removing references to ephemeral values, or by carefully using the ephemeralasnull() function if the expression will not reveal the ephemeral data.`, + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + errorMessage = "The error message included an ephemeral value, so it will not be displayed." + } else { + errorMessage = strings.TrimSpace(errorValue.AsString()) + } } } - } else { + } + if errorMessage == "" { errorMessage = "Failed to evaluate condition error message." } + // If the condition evaluated to true, the validation passed. We've validated + // the error message above, so any structural issues are already reported. + if result.True() { + return diags + } + // Construct the validation failure diagnostic. // The detail includes both the custom error message and a reference to where // the validation rule is defined, helping users locate the validation in their config. - detail := fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", - errorMessage, - validation.DeclRange.String()) - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: errInvalidValue, - Detail: detail, - Subject: &valueRng, + Severity: hcl.DiagError, + Summary: errInvalidValue, + Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", errorMessage, validation.DeclRange.String()), + Subject: &valueRng, + Expression: validation.Condition, + EvalContext: hclCtx, }) return diags diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go index 67a69ccbff..2288c62b24 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go @@ -5,10 +5,15 @@ package stackeval import ( "context" + "fmt" + "strings" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hcltest" "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" "google.golang.org/protobuf/encoding/prototext" @@ -17,9 +22,13 @@ import ( "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/promising" + "github.com/hashicorp/terraform/internal/providers" + testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackplan" "github.com/hashicorp/terraform/internal/stacks/stackstate" + "github.com/hashicorp/terraform/internal/tfdiags" ) func TestInputVariableValue(t *testing.T) { @@ -487,3 +496,604 @@ func TestInputVariablePlanChanges(t *testing.T) { }) } } + +// TestEvalVariableValidation tests the evalVariableValidation function directly, +// covering all the "invalid" cases: sensitive/ephemeral values in the error message, +// unknown/null condition results, and unknown error messages. These tests +// exercise the logic independently of the full stack-evaluator machinery. +func TestEvalVariableValidation(t *testing.T) { + // parseExpr parses a real HCL expression from a source string. + parseExpr := func(t *testing.T, src string) hcl.Expression { + t.Helper() + expr, diags := hclsyntax.ParseExpression([]byte(src), "test.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse expression %q: %s", src, diags.Error()) + } + return expr + } + + // makeFakeRule builds a minimal stackconfig.CheckRule from two expressions. + makeFakeRule := func(condition, errorMessage hcl.Expression) *stackconfig.CheckRule { + return &stackconfig.CheckRule{ + Condition: condition, + ErrorMessage: errorMessage, + DeclRange: hcl.Range{ + Filename: "test.hcl", + Start: hcl.Pos{Line: 2, Column: 1}, + End: hcl.Pos{Line: 5, Column: 1}, + }, + } + } + + // makeVarCtx builds an HCL evaluation context that exposes var.foo = val. + makeVarCtx := func(val cty.Value) *hcl.EvalContext { + return &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + "foo": val, + }), + }, + } + } + + valueRange := hcl.Range{ + Filename: "test.hcl", + Start: hcl.Pos{Line: 1, Column: 1}, + End: hcl.Pos{Line: 1, Column: 10}, + } + + // --- Basic pass/fail --- + + t.Run("condition passes, clean message → no diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertNoDiags(t, diags) + }) + + t.Run("condition fails, clean message → Invalid value for variable", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Value must be 'good'.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Sensitive error message --- + + t.Run("condition passes, sensitive error_message → flagged even on success", func(t *testing.T) { + // The error_message evaluates to a sensitive string even though the + // condition passes. This structural problem must always be reported. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + // Condition passed, so there must be no "Invalid value for variable". + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed") + } + } + }) + + t.Run("condition fails, sensitive error_message → both diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Ephemeral error message --- + + t.Run("condition passes, ephemeral error_message → flagged even on success", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + // Condition passed, so there must be no "Invalid value for variable". + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed") + } + } + }) + + t.Run("condition fails, ephemeral error_message → both diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Unknown / null condition results --- + + t.Run("condition result unknown → no diagnostics", func(t *testing.T) { + // Unknown condition means we cannot determine validity yet; skip quietly. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.UnknownVal(cty.Bool)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.UnknownVal(cty.String)), valueRange) + assertNoDiags(t, diags) + }) + + t.Run("condition result null → Invalid variable validation result", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.NullVal(cty.Bool)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid variable validation result" + }) + }) + + // --- Unknown error message --- + + t.Run("error message unknown, condition fails → Invalid error message (no failure diag)", func(t *testing.T) { + // We must flag the unknown error message but must NOT also emit + // "Invalid value for variable" because we return early. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when error message is unknown") + } + } + }) + + t.Run("error message unknown, condition passes → no diagnostic about error message", func(t *testing.T) { + // Unknown error message is only a problem when the condition actually + // fails (because we'd need to display it). When the condition passes + // the unknown error message must be silently ignored. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertNoDiags(t, diags) + }) + + // --- Sensitive variable value in condition expression --- + + t.Run("sensitive variable value, plain error message, condition fails → only Invalid value for variable", func(t *testing.T) { + // var.foo carries a sensitive mark. The condition expression + // (var.foo == "good") evaluates to a sensitive bool; Unmark() peels + // off the mark so the check works correctly. The error_message is a + // plain literal → no "Error message refers to sensitive values" diag. + hclCtx := makeVarCtx(cty.StringVal("bad").Mark(marks.Sensitive)) + rule := makeFakeRule( + parseExpr(t, `var.foo == "good"`), + hcltest.MockExprLiteral(cty.StringVal("Value is not allowed.")), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + for _, d := range diags { + if d.Description().Summary == "Error message refers to sensitive values" { + t.Errorf("unexpected 'Error message refers to sensitive values' when error message is plain text") + } + } + }) + + t.Run("sensitive variable referenced in error message, condition fails → both diagnostics", func(t *testing.T) { + // When the error_message interpolates a sensitive variable the + // evaluated message is itself sensitive — both the sensitive-value + // diagnostic and the generic failure diagnostic must be emitted. + hclCtx := makeVarCtx(cty.StringVal("secret").Mark(marks.Sensitive)) + rule := makeFakeRule( + parseExpr(t, `var.foo == "good"`), + parseExpr(t, `"Value '${var.foo}' is not allowed."`), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + t.Run("ephemeral variable referenced in error message, condition passes → flagged even on success", func(t *testing.T) { + // The condition passes but the error_message references an ephemeral + // variable, making the message itself ephemeral. This structural + // problem must still be reported. + hclCtx := makeVarCtx(cty.StringVal("good").Mark(marks.Ephemeral)) + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + parseExpr(t, `"Value '${var.foo}' is not allowed."`), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when condition passed") + } + } + }) + + // --- Condition evaluation error --- + + t.Run("condition evaluation error → early return with HCL error", func(t *testing.T) { + // When the condition expression itself fails to evaluate (e.g. it references + // an undefined variable), evalVariableValidation must return early with the + // evaluation error and must NOT emit "Invalid value for variable" or + // "Invalid variable validation result". + rule := makeFakeRule( + parseExpr(t, "undefined_var.foo"), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + // hclCtx only has "var", so "undefined_var" is unknown → evaluation error. + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + if !diags.HasErrors() { + t.Fatal("expected at least one error diagnostic, got none") + } + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' on condition evaluation error") + } + if d.Description().Summary == "Invalid variable validation result" { + t.Errorf("unexpected 'Invalid variable validation result' on condition evaluation error") + } + } + }) + + // --- Non-bool condition result --- + + t.Run("condition result is non-bool (list) → Invalid variable validation result", func(t *testing.T) { + // A condition that returns a list (or any value that cannot be converted + // to bool) hits the convert.Convert(result, cty.Bool) failure path. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid variable validation result" + }) + // Must return early — no "Invalid value for variable" should follow. + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when condition type conversion failed") + } + } + }) + + // --- Null error message --- + + t.Run("null error message, condition fails → Invalid value for variable with fallback text", func(t *testing.T) { + // A null error_message is skipped during string conversion; the framework + // falls back to "Failed to evaluate condition error message." in the + // detail of the "Invalid value for variable" diagnostic. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.NullVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" && + strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.") + }) + }) + + // --- Non-string error message --- + + t.Run("non-string error message (list), condition fails → Invalid error message + fallback in failure diag", func(t *testing.T) { + // An error_message that evaluates to a list (unconvertible to string) + // hits the convert.Convert(errorValue, cty.String) failure path. Both + // "Invalid error message" and "Invalid value for variable" (with the + // fallback text) must be emitted. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" && + strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.") + }) + }) +} + +// TestInputVariableValidation exercises evalVariableValidations end-to-end +// through CheckValue, using the "validation" fixture that declares variables +// with validation blocks. +func TestInputVariableValidation(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation") + + tests := map[string]struct { + varName string + inputVal cty.Value + wantSummaries []string // diagnostics that MUST be present (by Summary) + wantNoErrors bool // if true, no error diagnostics are expected + }{ + // --- validated (plain error message) --- + "validated: clean pass": { + varName: "validated", + inputVal: cty.StringVal("good"), + wantNoErrors: true, + }, + "validated: clean fail": { + varName: "validated", + inputVal: cty.StringVal("bad"), + wantSummaries: []string{"Invalid value for variable"}, + }, + + // --- with_msg_ref (error message interpolates var.with_msg_ref) --- + "with_msg_ref: clean pass": { + varName: "with_msg_ref", + inputVal: cty.StringVal("good"), + wantNoErrors: true, + }, + "with_msg_ref: clean fail": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad"), + wantSummaries: []string{"Invalid value for variable"}, + }, + "with_msg_ref: sensitive value passes → error message diag only": { + // Condition passes, but the interpolated error_message is sensitive + // → we should still flag the structural problem. + varName: "with_msg_ref", + inputVal: cty.StringVal("good").Mark(marks.Sensitive), + wantSummaries: []string{"Error message refers to sensitive values"}, + }, + "with_msg_ref: sensitive value fails → both diags": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad").Mark(marks.Sensitive), + wantSummaries: []string{ + "Error message refers to sensitive values", + "Invalid value for variable", + }, + }, + "with_msg_ref: ephemeral value passes → error message diag only": { + varName: "with_msg_ref", + inputVal: cty.StringVal("good").Mark(marks.Ephemeral), + wantSummaries: []string{"Error message refers to ephemeral values"}, + }, + "with_msg_ref: ephemeral value fails → both diags": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad").Mark(marks.Ephemeral), + wantSummaries: []string{ + "Error message refers to ephemeral values", + "Invalid value for variable", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + tc.varName: tc.inputVal, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: tc.varName}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + + if tc.wantNoErrors { + if diags.HasErrors() { + t.Errorf("unexpected errors: %s", diags.Err()) + } + return + } + + for _, wantSummary := range tc.wantSummaries { + wantSummary := wantSummary // capture for closure + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == wantSummary + }) + } + }) + }) + } +} + +// TestInputVariableValidationWithProviderFunction verifies that provider-defined +// functions can be called inside a variable validation condition expression. +// It uses the "validation_provider_function" fixture together with a mock provider +// that exposes a simple "upper" string function. +func TestInputVariableValidationWithProviderFunction(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation_provider_function") + providerTypeAddr := addrs.MustParseProviderSourceString("terraform.io/builtin/testing") + + newMockProvider := func(t *testing.T) (*testing_provider.MockProvider, providers.Factory) { + t.Helper() + mockProvider := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Functions: map[string]providers.FunctionDecl{ + "upper": { + Parameters: []providers.FunctionParam{ + {Name: "input", Type: cty.String}, + }, + ReturnType: cty.String, + Summary: "Converts a string to upper-case.", + }, + }, + }, + CallFunctionFn: func(req providers.CallFunctionRequest) providers.CallFunctionResponse { + if req.FunctionName != "upper" { + return providers.CallFunctionResponse{ + Err: fmt.Errorf("unexpected function call: %s", req.FunctionName), + } + } + input, _ := req.Arguments[0].Unmark() + return providers.CallFunctionResponse{ + Result: cty.StringVal(strings.ToUpper(input.AsString())), + } + }, + } + return mockProvider, providers.FactoryFixed(mockProvider) + } + + t.Run("passes validation", func(t *testing.T) { + _, providerFactory := newMockProvider(t) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "foo": cty.StringVal("hello"), // upper("hello") == "HELLO" → condition passes + }, + ProviderFactories: ProviderFactories{ + providerTypeAddr: providerFactory, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + assertNoDiags(t, diags) + }) + }) + + t.Run("fails validation", func(t *testing.T) { + _, providerFactory := newMockProvider(t) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "foo": cty.StringVal("world"), // upper("world") == "WORLD" ≠ "HELLO" → condition fails + }, + ProviderFactories: ProviderFactories{ + providerTypeAddr: providerFactory, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + }) +} + +// TestInputVariableMultipleValidationRules verifies that when a variable has +// more than one validation block, every failing rule produces its own +// diagnostic — i.e., all rules are evaluated and none are short-circuited. +// +// The "multi_rule" variable in the "validation" fixture has two rules: +// +// Rule 1: length(var.multi_rule) >= 5 +// Rule 2: var.multi_rule != "bad" +// +// The value "bad" has length 3 (< 5) and equals "bad", so it violates both +// rules simultaneously, giving us exactly two "Invalid value for variable" +// diagnostics. +func TestInputVariableMultipleValidationRules(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation") + + tests := map[string]struct { + inputVal cty.Value + wantErrCount int // expected number of "Invalid value for variable" diagnostics + }{ + "passes both rules": { + inputVal: cty.StringVal("hello"), // length 5 >= 5, != "bad" + wantErrCount: 0, + }, + "fails first rule only": { + inputVal: cty.StringVal("hi"), // length 2 < 5, != "bad" + wantErrCount: 1, + }, + "fails second rule only": { + // length("hello!") = 6 >= 5 → first passes; "hello!" != "bad" → second passes. + // To fail only the second rule we need length >= 5 AND value == "bad". + // "bad" itself has length 3, so the only way to isolate rule 2 failure + // is with a longer value that equals "bad" — impossible for a plain + // string. We therefore omit this sub-case and rely on the unit-level + // TestEvalVariableValidation coverage instead. + inputVal: cty.StringVal("hello"), // deliberately a pass case + wantErrCount: 0, + }, + "fails both rules": { + inputVal: cty.StringVal("bad"), // length 3 < 5 AND == "bad" + wantErrCount: 2, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "multi_rule": tc.inputVal, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "multi_rule"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + + var failCount int + for _, d := range diags { + if d.Severity() == tfdiags.Error && d.Description().Summary == "Invalid value for variable" { + failCount++ + } + } + if failCount != tc.wantErrCount { + t.Errorf("expected %d 'Invalid value for variable' diagnostic(s), got %d; diags:\n%s", + tc.wantErrCount, failCount, diags.ErrWithWarnings()) + } + }) + }) + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl new file mode 100644 index 0000000000..5e80f82b09 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl @@ -0,0 +1,43 @@ + +# A variable with a simple validation that does not reference the variable +# value inside the error_message. +variable "validated" { + type = string + + validation { + condition = var.validated != "bad" + error_message = "Value must not be 'bad'." + } +} + +# A variable whose error_message expression interpolates the variable value. +# When the input carries a sensitive or ephemeral mark, evaluating the +# interpolation causes the error_message result to inherit that mark — which +# is the behaviour we want to exercise. +variable "with_msg_ref" { + type = string + + validation { + condition = var.with_msg_ref != "bad" + error_message = "Got disallowed value '${var.with_msg_ref}'." + } +} + +# A variable with two validation blocks to verify that all rules are evaluated +# and all failures are reported independently. +# +# Inputs that trigger both failures simultaneously: +# "bad" — length("bad") = 3 < 5 AND "bad" == "bad" +variable "multi_rule" { + type = string + + validation { + condition = length(var.multi_rule) >= 5 + error_message = "Value must be at least 5 characters long." + } + + validation { + condition = var.multi_rule != "bad" + error_message = "Value must not be 'bad'." + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl new file mode 100644 index 0000000000..15b9ac35ea --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl @@ -0,0 +1,25 @@ + +# This fixture is used by TestInputVariableValidationWithProviderFunction to +# verify that provider-defined functions can be called inside a validation +# condition expression. +# +# The mock test provider exposes a single function "upper" that converts a +# string to upper-case; the validation here checks that the given value +# equals "HELLO" when converted to upper-case. + +required_providers { + testing = { + source = "terraform.io/builtin/testing" + } +} + +provider "testing" "main" {} + +variable "foo" { + type = string + + validation { + condition = provider::testing::upper(var.foo) == "HELLO" + error_message = "Value must equal 'hello' (case-insensitive)." + } +} diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 831101c981..18c0683f1a 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6607,6 +6607,85 @@ func TestPlan_variableValidationAdvanced(t *testing.T) { }, wantErrorMessages: []string{"Tag values must be 1-256 characters."}, }, + + // Invalid error message tests - these verify that invalid error messages + // are caught even when validation passes or fails + "invalid-error-message-sensitive-in-error": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("short"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.NumberIntVal(5), + "api_key": cty.StringVal("abcdef0123456789"), + }, + wantErrorMessages: []string{ + "error expression used to explain this condition refers to sensitive values", + }, + }, + "invalid-error-message-ephemeral-in-error": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("short_token"), + "count_value": cty.NumberIntVal(5), + "api_key": cty.StringVal("abcdef0123456789"), + }, + wantErrorMessages: []string{ + "error expression used to explain this condition refers to ephemeral values", + }, + }, + "invalid-error-message-not-string": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.NumberIntVal(-5), + "api_key": cty.StringVal("abcdef0123456789"), + }, + // When error_message is not a string type, we get the raw value in the validation failure + wantErrorMessages: []string{ + "-5", + }, + }, + "invalid-error-message-sensitive-even-when-passing": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.NumberIntVal(5), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789abcdef0123456789"), + }, + // This tests that we evaluate error_message even when validation passes + wantErrorMessages: []string{ + "error expression used to explain this condition refers to sensitive values", + }, + }, + + // Provider function tests + "provider-functions-pass": { + configPath: path.Join("with-single-input", "validation-provider-functions"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "echo_value": cty.StringVal("test_value"), + "combined": cty.StringVal("long_enough"), + }, + wantErrorMessages: nil, + }, + "provider-functions-fail": { + configPath: path.Join("with-single-input", "validation-provider-functions"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "echo_value": cty.StringVal("test"), + "combined": cty.StringVal("short"), + }, + wantErrorMessages: []string{ + "Combined value must be longer than 5 characters after echo", + }, + }, } for name, tc := range testCases { diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl new file mode 100644 index 0000000000..d7c90622c4 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl @@ -0,0 +1,68 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +# Test case: error_message references sensitive value +variable "password" { + type = string + sensitive = true + + validation { + condition = length(var.password) >= 8 + error_message = "Password '${var.password}' is too short." + } +} + +# Test case: error_message references ephemeral value +variable "token" { + type = string + ephemeral = true + + validation { + condition = length(var.token) == 32 + error_message = "Token '${var.token}' is invalid." + } +} + +# Test case: error_message that is not a string +variable "count_value" { + type = number + + validation { + condition = var.count_value > 0 + error_message = var.count_value # Invalid: should be a string + } +} + +# Test case: error_message references sensitive value even when validation passes +variable "api_key" { + type = string + sensitive = true + + validation { + condition = length(var.api_key) >= 16 + error_message = "API key '${var.api_key}' must be at least 16 characters." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl new file mode 100644 index 0000000000..ae6770daaf --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl @@ -0,0 +1,45 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +# Test case: Validation using provider-defined function in condition +variable "echo_value" { + type = string + + validation { + condition = provider::testing::echo(var.echo_value) == var.echo_value + error_message = "Echo function did not return the same value." + } +} + +# Test case: Validation using provider function with built-in functions +variable "combined" { + type = string + + validation { + condition = length(provider::testing::echo(var.combined)) > 5 + error_message = "Combined value must be longer than 5 characters after echo." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {}