Files
ConfigKit/internal/engine/validation.go

287 lines
9.5 KiB
Go

package engine
import (
"reflect"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Validation collects safe application diagnostics during a supported callback.
// A callback must not retain the context or use it concurrently.
type Validation struct {
state *validationState
}
type validationState struct {
active bool
errorCode Code
warningCode Code
invalidPath Code
schema *schemaDescriptor
document *document.Node
diagnostics []Diagnostic
}
// Error reports a value-free application error at path. Related paths are
// resolved to source locations when possible.
func (v *Validation) Error(path Path, message string, related ...Path) {
v.report(SeverityError, path, message, related)
}
// Warning reports a value-free application warning at path. Related paths are
// resolved to source locations when possible.
func (v *Validation) Warning(path Path, message string, related ...Path) {
v.report(SeverityWarning, path, message, related)
}
func (v *Validation) report(severity Severity, path Path, message string, related []Path) {
if v == nil || v.state == nil || !v.state.active {
panic(closedCallbackContextMessage)
}
code := v.state.errorCode
if severity == SeverityWarning {
code = v.state.warningCode
}
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
if !path.valid() {
invalidCode := v.state.invalidPath
if invalidCode == "" {
invalidCode = v.state.errorCode
}
diagnostic.Severity = SeverityError
diagnostic.Code = invalidCode
diagnostic.Path = Path{}
diagnostic.Message = "application reported an invalid configuration path"
} else if v.state.schema != nil {
if _, ok := resolveSchemaPath(v.state.schema, path); !ok {
diagnostic.Severity = SeverityError
diagnostic.Code = v.state.invalidPath
diagnostic.Message = "application reported a path outside the configuration schema"
v.state.diagnostics = append(v.state.diagnostics, diagnostic)
return
}
if location, ok := nearestDocumentLocation(v.state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
} else if location, ok := nearestDocumentLocation(v.state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
for _, relatedPath := range related {
if !relatedPath.valid() {
invalidCode := v.state.invalidPath
if invalidCode == "" {
invalidCode = v.state.errorCode
}
v.state.diagnostics = append(v.state.diagnostics, Diagnostic{
Code: invalidCode, Severity: SeverityError,
Message: "application reported an invalid related configuration path",
})
continue
}
if v.state.schema != nil {
if _, ok := resolveSchemaPath(v.state.schema, relatedPath); !ok {
v.state.diagnostics = append(v.state.diagnostics, Diagnostic{
Code: v.state.invalidPath, Severity: SeverityError, Path: relatedPath.clone(),
Message: "application reported a related path outside the configuration schema",
})
continue
}
}
if location, ok := nearestDocumentLocation(v.state.document, relatedPath); ok {
diagnostic.Related = append(diagnostic.Related, Location{
SourceID: location.SourceID, Line: location.Line, Column: location.Column,
})
}
}
v.state.diagnostics = append(v.state.diagnostics, diagnostic)
}
func runValidation[T any](configuration *T, validator func(*T, *Validation), schema *schemaDescriptor, before, input *document.Node, lookups map[string]secretLookupResult) []Diagnostic {
state := &validationState{
active: true, errorCode: CodeValidateError, warningCode: CodeValidateWarning,
invalidPath: CodeValidateInvalidPath, schema: schema, document: input,
}
context := &Validation{state: state}
func() {
defer func() { state.active = false }()
validator(configuration, context)
}()
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
if issue != nil {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
return canonicalDiagnostics(state.diagnostics)
}
state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...)
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema, lookups) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
}
return canonicalDiagnostics(state.diagnostics)
}
func validationMutationDiagnostic(path Path) Diagnostic {
return Diagnostic{
Code: CodeValidateMutation, Severity: SeverityError, Path: path.clone(),
Message: "application validation mutated the configuration",
}
}
func nearestDocumentLocation(root *document.Node, path Path) (document.Location, bool) {
return nearestDocumentLocationWithLookup(root, path, nil)
}
func nearestDocumentLocationWithLookup(root *document.Node, path Path, lookup *documentLookup) (document.Location, bool) {
if root == nil || !path.valid() {
return document.Location{}, false
}
current := root
nearest := current.Location
for _, segment := range path.segments {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return nearest, locationKnown(nearest)
}
position := -1
if lookup != nil {
position = lookup.find(current, segment.key)
} else {
for index, entry := range current.Mapping {
if entry.Key == segment.key {
position = index
break
}
}
}
if position < 0 {
return nearest, locationKnown(nearest)
}
current = current.Mapping[position].Value
nearest = current.Location
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) {
return nearest, locationKnown(nearest)
}
current = current.Sequence[index]
nearest = current.Location
default:
return nearest, locationKnown(nearest)
}
}
return nearest, locationKnown(nearest)
}
func locationKnown(location document.Location) bool {
return location.SourceID != "" || location.Line != 0 || location.Column != 0
}
// validationValueMutations combines value and sensitivity changes; resolved
// secret state is audited separately because it never enters projected trees.
func validationValueMutations(before, after *document.Node) []Diagnostic {
var diagnostics []Diagnostic
for _, delta := range compareConfigurationValues(before, after) {
diagnostics = append(diagnostics, validationMutationDiagnostic(delta.path))
}
beforeSensitivity := projectedSensitivity(before)
afterSensitivity := projectedSensitivity(after)
for pointer, path := range beforeSensitivity {
if _, exists := afterSensitivity[pointer]; !exists {
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
}
}
for pointer, path := range afterSensitivity {
if _, exists := beforeSensitivity[pointer]; !exists {
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
}
}
return diagnostics
}
type versionCheckResult struct {
version Version
diagnostics []Diagnostic
}
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
return checkVersionResult(rule, root).diagnostics
}
func checkVersionResult(rule *VersionRule, root *document.Node) versionCheckResult {
if rule == nil {
return versionCheckResult{}
}
result := interpretVersion(rule, root)
if hasErrors(result.diagnostics) {
result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result
}
result.diagnostics = append(result.diagnostics, reportVersion(rule, result.version, root)...)
result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result
}
func interpretVersion(rule *VersionRule, root *document.Node) versionCheckResult {
result := versionCheckResult{}
node, present := documentAtPath(root, rule.Path)
if present {
result.version.Present = true
switch node.Kind {
case document.Null:
result.version.Null = true
case document.String:
result.version.Text = node.Text
case document.Number:
if isIntegerVersion(node) {
result.version.Text = node.Text
return result
}
fallthrough
default:
location, _ := nearestDocumentLocation(root, rule.Path)
primary := documentLocationPointer(location)
result.diagnostics = []Diagnostic{{
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
Message: "schema version must be a string, integer, or null", Primary: primary,
}}
return result
}
}
return result
}
func reportVersion(rule *VersionRule, version Version, root *document.Node) []Diagnostic {
state := &validationState{
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
invalidPath: CodeVersionError, document: root,
}
validation := &Validation{state: state}
var err error
func() {
defer func() { state.active = false }()
err = rule.Check(version, validation)
}()
if err != nil && !hasErrors(state.diagnostics) {
location, _ := nearestDocumentLocation(root, rule.Path)
state.diagnostics = append(state.diagnostics, Diagnostic{
Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(),
Message: "schema-version check failed", Primary: documentLocationPointer(location),
})
}
return state.diagnostics
}
func isIntegerVersion(node *document.Node) bool {
return node != nil && node.Kind == document.Number && integerPattern.MatchString(node.Text)
}
func pathMatches(configured *Path, current Path) bool {
return configured != nil && current.valid() && matchesPathShape(*configured, current)
}
func documentLocationPointer(location document.Location) *Location {
if !locationKnown(location) {
return nil
}
return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
}