constellation/internal/validation/errors.go
Moritz Sanft 744a605602
cli: state file validation (#2523)
* re-use `ReadFromFile` in `CreateOrRead`

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* [wip]: add constraints

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* [wip] error formatting

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* wip

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* formatted error messages

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* state file validation

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* linter fixes

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* allow overriding the constraints

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* dont validate on read

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* add pre-create constraints

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* [wip]

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* finish pre-init validation test

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* finish post-init validation

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* use state file validation in CLI

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* fix apply tests

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* Update internal/validation/errors.go

Co-authored-by: Daniel Weiße <66256922+daniel-weisse@users.noreply.github.com>

* use transformator for tests

* tidy

* use empty check directly

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* Update cli/internal/state/state.go

Co-authored-by: Daniel Weiße <66256922+daniel-weisse@users.noreply.github.com>

* Update cli/internal/state/state.go

Co-authored-by: Daniel Weiße <66256922+daniel-weisse@users.noreply.github.com>

* Update cli/internal/state/state.go

Co-authored-by: Daniel Weiße <66256922+daniel-weisse@users.noreply.github.com>

* Update cli/internal/state/state.go

Co-authored-by: Daniel Weiße <66256922+daniel-weisse@users.noreply.github.com>

* conditional validation per CSP

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* tidy

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* fix rebase

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* add default case

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* validate state-file as last input

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

---------

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>
Co-authored-by: Daniel Weiße <66256922+daniel-weisse@users.noreply.github.com>
2023-11-03 15:47:03 +01:00

318 lines
8.9 KiB
Go

/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package validation
import (
"errors"
"fmt"
"reflect"
"strings"
)
// TreeError is returned when a document is not valid.
// It contains the path to the field that failed validation, the error
// that occurred, as well as a list of child errors, as one constraint
// can embed multiple other constraints, e.g. in an OR.
type TreeError struct {
path string
err error
children []*TreeError
}
// NewErrorTree creates a new error tree from the given error.
func NewErrorTree(err error) *TreeError {
return &TreeError{
err: err,
children: []*TreeError{},
}
}
/*
newTraceError creates a new validation error, traced to a field.
To find the path to the exported field that failed validation, it traverses "doc"
recursively until it finds a field in "doc" that matches the reference to "field".
*/
func newTraceError(doc, field referenceableValue, errMsg error) *TreeError {
// traverse the top level struct (i.e. the "haystack") until addr (i.e. the "needle") is found
path, err := traverse(doc, field, newPathBuilder(doc._type.Name()))
if err != nil {
return &TreeError{
path: "unknown",
err: fmt.Errorf("cannot find path to field: %w. original error: %w", err, errMsg),
}
}
return &TreeError{
path: path,
err: errMsg,
children: []*TreeError{},
}
}
// Error implements the error interface.
func (e *TreeError) Error() string {
return e.format(0)
}
// Unwrap implements the error interface.
func (e *TreeError) Unwrap() error {
return e.err
}
// format formats the error tree and all of its children.
func (e *TreeError) format(indent int) string {
var sb strings.Builder
if e.path != "" {
sb.WriteString(fmt.Sprintf(
"%svalidating %s: %s",
strings.Repeat(" ", indent),
e.path,
e.err,
))
} else {
sb.WriteString(fmt.Sprintf(
"%s%s",
strings.Repeat(" ", indent),
e.err,
))
}
for _, child := range e.children {
sb.WriteString(fmt.Sprintf(
"\n%s",
child.format(indent+1),
))
}
return sb.String()
}
// appendChild adds the given child error to the tree.
func (e *TreeError) appendChild(child *TreeError) {
e.children = append(e.children, child)
}
/*
traverse "haystack" recursively until it finds a field that matches
the reference saved in "needle", while building a pseudo-JSONPath to the field.
If it traverses a level down, it appends the name of the struct tag
or another entity like array index or map field to path.
When a field matches the reference to the given field, it returns the
path to the field.
*/
func traverse(haystack referenceableValue, needle referenceableValue, path pathBuilder) (string, error) {
// recursion anchor: doc is the field we are looking for.
// Join the path and return.
if foundNeedle(haystack, needle) {
return path.string(), nil
}
kind := haystack._type.Kind()
switch kind {
case reflect.Struct:
// Traverse all visible struct fields.
for _, field := range reflect.VisibleFields(haystack._type) {
// skip unexported fields
if !field.IsExported() {
continue
}
fieldVal := recPointerDeref(haystack.value.FieldByName(field.Name))
if isNilPtrOrInvalid(fieldVal) {
continue
}
fieldAddr := haystack.addr + field.Offset
newHaystack := referenceableValue{
value: fieldVal,
addr: fieldVal.UnsafeAddr(),
_type: fieldVal.Type(),
}
if canTraverse(fieldVal) {
// When a field is not the needle and cannot be traversed further,
// a errCannotTraverse is returned. Therefore, we only want to handle
// the case where the field is the needle.
if path, err := traverse(newHaystack, needle, path.appendStructField(field)); err == nil {
return path, nil
}
}
if foundNeedle(referenceableValue{addr: fieldAddr, _type: field.Type}, needle) {
return path.appendStructField(field).string(), nil
}
}
case reflect.Slice, reflect.Array:
// Traverse slice / Array elements
for i := 0; i < haystack.value.Len(); i++ {
// see struct case
itemVal := recPointerDeref(haystack.value.Index(i))
if isNilPtrOrInvalid(itemVal) {
continue
}
newHaystack := referenceableValue{
value: itemVal,
addr: itemVal.UnsafeAddr(),
_type: itemVal.Type(),
}
if canTraverse(itemVal) {
if path, err := traverse(newHaystack, needle, path.appendArrayIndex(i)); err == nil {
return path, nil
}
}
if foundNeedle(newHaystack, needle) {
return path.appendArrayIndex(i).string(), nil
}
}
case reflect.Map:
// Traverse map elements
iter := haystack.value.MapRange()
for iter.Next() {
// see struct case
mapKey := iter.Key().String()
mapVal := recPointerDeref(iter.Value())
if isNilPtrOrInvalid(mapVal) {
continue
}
if canTraverse(mapVal) {
newHaystack := referenceableValue{
value: mapVal,
addr: mapVal.UnsafeAddr(),
_type: mapVal.Type(),
mapKey: mapKey,
}
if path, err := traverse(newHaystack, needle, path.appendMapKey(mapKey)); err == nil {
return path, nil
}
}
// check if reference to map is the needle and the map key matches
if foundNeedle(referenceableValue{addr: haystack.addr, _type: haystack._type, mapKey: mapKey}, needle) {
return path.appendMapKey(mapKey).string(), nil
}
}
}
// Primitive type, but not the value we are looking for.
return "", errCannotTraverse
}
// referenceableValue is a type that can be passed as any (thus being copied) without losing the reference to the actual value.
type referenceableValue struct {
value reflect.Value
_type reflect.Type
mapKey string // special case for map values, which are not addressable
addr uintptr
}
// errCannotTraverse is returned when a field cannot be traversed further.
var errCannotTraverse = errors.New("cannot traverse anymore")
// recPointerDeref recursively dereferences pointers and unpacks interfaces until a non-pointer value is found.
func recPointerDeref(val reflect.Value) reflect.Value {
switch val.Kind() {
case reflect.Ptr, reflect.UnsafePointer, reflect.Interface:
return recPointerDeref(val.Elem())
}
return val
}
// pointerDeref dereferences pointers and unpacks interfaces.
// If the value is not a pointer, it is returned unchanged.
func pointerDeref(val reflect.Value) reflect.Value {
switch val.Kind() {
case reflect.Ptr, reflect.UnsafePointer, reflect.Interface:
return val.Elem()
}
return val
}
/*
canTraverse whether a value can be further traversed.
For pointer types, false is returned.
*/
func canTraverse(v reflect.Value) bool {
switch v.Kind() {
case reflect.Struct, reflect.Slice, reflect.Array, reflect.Map:
return true
}
return false
}
// isNilPtrOrInvalid returns true if a value is a nil pointer or if the value is of an invalid kind.
func isNilPtrOrInvalid(v reflect.Value) bool {
switch v.Kind() {
case reflect.Ptr, reflect.UnsafePointer, reflect.Interface, reflect.Slice, reflect.Map:
return v.IsNil()
case reflect.Invalid:
return true
}
return false
}
/*
foundNeedle returns whether the given value is the needle.
It does so by comparing the address and type of the value to the address and type of the needle.
The comparison of types is necessary because the first value of a struct has the same address as the struct itself.
*/
func foundNeedle(haystack, needle referenceableValue) bool {
return haystack.addr == needle.addr &&
haystack._type == needle._type &&
haystack.mapKey == needle.mapKey
}
// pathBuilder is a helper to build a field path.
type pathBuilder struct {
buf []string // slice can be copied by value when its non-zero, contrary to a strings.Builder
}
// newPathBuilder creates a new pathBuilder from the identifier of a top level document.
func newPathBuilder(topLevelDoc string) pathBuilder {
return pathBuilder{
buf: []string{topLevelDoc},
}
}
// appendStructField appends the JSON / YAML struct tag of a field to the path.
// If no struct tag is present, the field name is used.
func (p pathBuilder) appendStructField(field reflect.StructField) pathBuilder {
switch {
case field.Tag.Get("json") != "":
// cut off omitempty or other options
jsonTagName, _, _ := strings.Cut(field.Tag.Get("json"), ",")
p.buf = append(p.buf, fmt.Sprintf(".%s", jsonTagName))
case field.Tag.Get("yaml") != "":
// cut off omitempty or other options
yamlTagName, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
p.buf = append(p.buf, fmt.Sprintf(".%s", yamlTagName))
default:
p.buf = append(p.buf, fmt.Sprintf(".%s", field.Name))
}
return p
}
// appendArrayIndex appends the index of an array to the path.
func (p pathBuilder) appendArrayIndex(i int) pathBuilder {
p.buf = append(p.buf, fmt.Sprintf("[%d]", i))
return p
}
// appendMapKey appends the key of a map to the path.
func (p pathBuilder) appendMapKey(k string) pathBuilder {
p.buf = append(p.buf, fmt.Sprintf("[\"%s\"]", k))
return p
}
// string returns the path.
func (p pathBuilder) string() string {
// Remove struct tag prefix
return strings.TrimPrefix(
strings.Join(p.buf, ""),
".",
)
}