189 lines
6.5 KiB
Go
189 lines
6.5 KiB
Go
package configkit
|
|
|
|
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}
|
|
validator(configuration, context)
|
|
state.active = false
|
|
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.root, 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) {
|
|
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)
|
|
}
|
|
found := false
|
|
for _, entry := range current.Mapping {
|
|
if entry.Key == segment.key {
|
|
current = entry.Value
|
|
nearest = current.Location
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nearest, locationKnown(nearest)
|
|
}
|
|
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
|
|
}
|