Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/configs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
WORKING_DIR: 'src'
RUST_TOOLCHAIN: '1.96.0' # keep in sync with src/rust-toolchain.toml
NODE_VERSION: '22.x'
PYTHON_VERSION: '3.10'
PYTHON_VERSION: '3.9'
GO_VERSION: '1.26'
UNIFFI_BINDGEN_GO_TAG: 'v0.7.1+v0.31.0' # keep in sync with bindings-go/README.md and the uniffi pin in bindings-go/Cargo.toml
JAVA_VERSION: '21'
Expand Down
2 changes: 1 addition & 1 deletion INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ See the [Node.js API and examples](src/bindings-wasm/README.md).
### Python

Production versions are published to [PyPI](https://pypi.org/project/cloudformation-validate/); prereleases are
published to [TestPyPI](https://test.pypi.org/project/cloudformation-validate/). The package requires Python 3.10 or
published to [TestPyPI](https://test.pypi.org/project/cloudformation-validate/). The package requires Python 3.9 or
later, and its platform-specific wheels have no runtime package dependencies.

```bash
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ JVM library (Kotlin/Java) - all backed by the same validation core.
(`--additional-schema`, or `EngineConfig.schema_validator_config.additional_schemas` when embedding).
- **Custom rules.** Extend validation with your own rules in CEL (JSON), Rego, or
[CloudFormation Guard](https://docs.aws.amazon.com/cfn-guard/latest/ug/what-is-guard.html) DSL.
- **AWS CLI command validation.** Model a create or update API call as CloudFormation resource state and validate it
offline before it is sent; any call that cannot be modeled exactly is skipped, never guessed (see
[validation-engine/API.md](src/validation-engine/API.md#validating-an-aws-cli-command)).
- **Embeddable everywhere.** Use it from the CLI, Rust, Node.js, Python, Go, or the JVM.
- **Sub-second** validation for typical templates.

Expand Down
250 changes: 250 additions & 0 deletions src/bindings-go/go/cfnvalidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@
package cfnvalidate

import (
"bytes"
"encoding/json"
"fmt"
"math"
"os"
"reflect"
"strconv"
"strings"
"time"

bindings "github.com/aws-cloudformation/cloudformation-validate/src/bindings-go/go/internal/bindings_go"
)
Expand Down Expand Up @@ -83,6 +89,7 @@ func decodeInto[T any](data string, what string) (*T, error) {
// nativeEngine is the method set shared by the generated engine objects.
type nativeEngine interface {
ValidateTemplateJson(template []byte, optionsJson string, filePath string) (string, error)
ValidateAwsCliCommandJson(requestJson string) (string, error)
ListRulesJson() (string, error)
EngineName() string
Destroy()
Expand Down Expand Up @@ -194,6 +201,249 @@ func (e *Engine) Destroy() {
e.inner.Destroy()
}

// ValidateAWSCLICommand classifies and validates an AWS CLI command against
// CloudFormation schemas and rules entirely offline, with a validation
// configuration fixed by the library. The result contains operation
// classification, resource type inference, and an optional ValidationReport
// when the request was validated (not skipped). The report carries only the
// shared diagnostic fields; enrichment fields are left nil.
func (e *Engine) ValidateAWSCLICommand(request AWSCLICommand) (*AWSCLICommandValidation, error) {
requestJSON, err := marshalAWSCLICommand(request)
if err != nil {
return nil, err
}
data, err := e.inner.ValidateAwsCliCommandJson(requestJSON)
if err != nil {
return nil, err
}
return decodeInto[AWSCLICommandValidation](data, "AWS CLI command validation")
}

// marshalAWSCLICommand encodes an AWSCLICommand into the wire JSON that the
// Rust side expects, converting Go parameter values into tagged AwsCliValue
// objects.
func marshalAWSCLICommand(request AWSCLICommand) (string, error) {
wire := awsCliCommandWire{
ServiceName: request.ServiceName,
OperationName: request.OperationName,
Parameters: make(map[string]awsCliValue, len(request.Parameters)),
ServicePrefix: nilIfEmpty(request.ServicePrefix),
HTTPMethod: nilIfEmpty(request.HTTPMethod),
IsReadOnly: request.IsReadOnly,
}
for key, value := range request.Parameters {
wire.Parameters[key] = encodeAwsCliValue(value, 0)
}
data, err := json.Marshal(wire)
if err != nil {
return "", fmt.Errorf("cfnvalidate: encoding AWS CLI command: %w", err)
}
return string(data), nil
}

func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}

// awsCliCommandWire is the JSON structure consumed by the Rust wire parser.
type awsCliCommandWire struct {
ServiceName string `json:"serviceName"`
OperationName string `json:"operationName"`
Parameters map[string]awsCliValue `json:"parameters"`
ServicePrefix *string `json:"servicePrefix,omitempty"`
HTTPMethod *string `json:"httpMethod,omitempty"`
IsReadOnly *bool `json:"isReadOnly,omitempty"`
}

// awsCliValue is the tagged union wire format matching the core AwsCliValue
// serde representation (tag = "type", rename_all = "SCREAMING_SNAKE_CASE").
// Items and Entries use pointer fields so that empty slices/maps serialize as
// their JSON zero ([] / {}) while remaining absent for unrelated variants.
type awsCliValue struct {
Type string `json:"type"`
Value any `json:"value,omitempty"`
Items *[]awsCliValue `json:"items,omitempty"`
Entries *map[string]awsCliValue `json:"entries,omitempty"`
TypeName string `json:"type_name,omitempty"`
}

// maxEncodeDepth prevents stack overflow on cyclic or deeply nested structures.
const maxEncodeDepth = 64

// encodeAwsCliValue recursively converts a Go value into the tagged wire
// format. It is non-mutating: no pointer is followed through a write path.
// Unsupported types are represented as UNSUPPORTED rather than coerced.
//
// SDK-defined type aliases (e.g. types.InstanceType is a named string) are
// handled via reflect.Kind after concrete type checks, so any alias of a
// scalar kind is encoded correctly without enumerating every SDK type.
func encodeAwsCliValue(v any, depth int) awsCliValue {
if depth > maxEncodeDepth {
return awsCliValue{Type: "UNSUPPORTED", TypeName: "recursion depth exceeded"}
}
if v == nil {
return awsCliValue{Type: "NULL"}
}

// Unwrap interface and pointer layers. Count indirections separately because
// a pointer-to-interface cycle can otherwise loop before recursive
// collection encoding reaches the depth guard.
rv := reflect.ValueOf(v)
indirections := 0
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
if depth+indirections > maxEncodeDepth {
return awsCliValue{Type: "UNSUPPORTED", TypeName: "recursion depth exceeded"}
}
if rv.IsNil() {
return awsCliValue{Type: "NULL"}
}
rv = rv.Elem()
indirections++
}
v = rv.Interface()

// Concrete type checks for stdlib types that carry semantics beyond their
// underlying kind (time.Time and json.Number).
switch val := v.(type) {
case time.Time:
return awsCliValue{Type: "STRING", Value: val.UTC().Format(time.RFC3339Nano)}

case json.Number:
text := string(val)
if i, err := val.Int64(); err == nil {
return awsCliValue{Type: "INTEGER", Value: i}
}
if u, err := strconv.ParseUint(text, 10, 64); err == nil {
return awsCliValue{Type: "UNSIGNED_INTEGER", Value: u}
}
if !strings.ContainsAny(text, ".eE") && json.Valid([]byte(text)) {
return awsCliValue{Type: "UNSUPPORTED", TypeName: "integer outside 64-bit range"}
}
if f, err := val.Float64(); err == nil {
if math.IsInf(f, 0) || math.IsNaN(f) {
return awsCliValue{Type: "UNSUPPORTED", TypeName: "non-finite floating-point number"}
}
return awsCliValue{Type: "NUMBER", Value: f}
}
return awsCliValue{Type: "UNSUPPORTED", TypeName: "unparseable json.Number"}
}

// Kind-based handling covers both built-in types and SDK-defined aliases
// (e.g. types.InstanceType is `type InstanceType string`).
switch rv.Kind() {
case reflect.Bool:
return awsCliValue{Type: "BOOLEAN", Value: rv.Bool()}

case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return awsCliValue{Type: "INTEGER", Value: rv.Int()}

case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return awsCliValue{Type: "UNSIGNED_INTEGER", Value: rv.Uint()}

case reflect.Float32, reflect.Float64:
f := rv.Float()
if math.IsInf(f, 0) || math.IsNaN(f) {
return awsCliValue{Type: "UNSUPPORTED", TypeName: "non-finite floating-point number"}
}
return awsCliValue{Type: "NUMBER", Value: f}

case reflect.String:
return awsCliValue{Type: "STRING", Value: rv.String()}

case reflect.Slice, reflect.Array:
// []byte / [N]byte → BYTES, encoded as a JSON integer array so the
// Rust side receives Vec<u8> from serde (encoding/json marshals
// []byte as base64 which is incompatible with serde's Vec<u8>).
if rv.Type().Elem().Kind() == reflect.Uint8 {
ints := make([]int, rv.Len())
for i := range ints {
ints[i] = int(rv.Index(i).Uint())
}
return awsCliValue{Type: "BYTES", Value: ints}
}
items := make([]awsCliValue, rv.Len())
for i := range items {
items[i] = encodeAwsCliValue(rv.Index(i).Interface(), depth+1)
}
return awsCliValue{Type: "ARRAY", Items: &items}

case reflect.Map:
if rv.Type().Key().Kind() != reflect.String {
return awsCliValue{Type: "UNSUPPORTED", TypeName: "mapping with non-string keys"}
}
entries := make(map[string]awsCliValue, rv.Len())
iter := rv.MapRange()
for iter.Next() {
entries[iter.Key().String()] = encodeAwsCliValue(iter.Value().Interface(), depth+1)
}
return awsCliValue{Type: "OBJECT", Entries: &entries}

case reflect.Struct:
return awsCliValue{Type: "UNSUPPORTED", TypeName: rv.Type().String()}

default:
return awsCliValue{Type: "UNSUPPORTED", TypeName: rv.Type().String()}
}
}

// UnmarshalJSON decodes an AWSCLICommandValidation, translating the core's
// integer-array encoding of the validated template into a byte slice.
//
// The core serializes the template as a JSON array of byte-valued integers
// (serde's representation of a byte vector), which encoding/json cannot decode
// into a []byte directly - it expects a base64 string. Every other field
// decodes with the standard rules. On any failure the receiver is left
// unchanged, so a decode error never leaves a partially populated result.
func (v *AWSCLICommandValidation) UnmarshalJSON(data []byte) error {
type withoutTemplate AWSCLICommandValidation
var wire struct {
withoutTemplate
Template json.RawMessage `json:"template"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
template, err := decodeTemplateBytes(wire.Template)
if err != nil {
return err
}
decoded := AWSCLICommandValidation(wire.withoutTemplate)
decoded.Template = template
*v = decoded
return nil
}

// decodeTemplateBytes converts the core's JSON integer-array template encoding
// into a byte slice. A missing or null field yields nil; an empty array yields
// a non-nil empty slice; every element must be an integer in the range 0-255.
func decodeTemplateBytes(raw json.RawMessage) ([]byte, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return nil, nil
}
decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.UseNumber()
var elements []json.Number
if err := decoder.Decode(&elements); err != nil {
return nil, fmt.Errorf("cfnvalidate: template must be a JSON array of byte integers: %w", err)
}
template := make([]byte, len(elements))
for i, element := range elements {
value, err := element.Int64()
if err != nil {
return nil, fmt.Errorf("cfnvalidate: template byte at index %d is not an integer: %s", i, element)
}
if value < 0 || value > 255 {
return nil, fmt.Errorf("cfnvalidate: template byte at index %d is out of range 0-255: %d", i, value)
}
template[i] = byte(value)
}
return template, nil
}

// SchemaValidator validates resources against the compiled CloudFormation
// provider schemas.
type SchemaValidator struct {
Expand Down
Loading
Loading