249 lines
8.3 KiB
Go
249 lines
8.3 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
|
|
}
|
|
|
|
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
|
if rule == nil {
|
|
return nil
|
|
}
|
|
version := Version{}
|
|
node, present := documentAtPath(root, rule.Path)
|
|
if present {
|
|
version.Present = true
|
|
switch node.Kind {
|
|
case document.Null:
|
|
version.Null = true
|
|
case document.String:
|
|
version.Text = node.Text
|
|
case document.Number:
|
|
if isIntegerVersion(node) {
|
|
version.Text = node.Text
|
|
break
|
|
}
|
|
fallthrough
|
|
default:
|
|
location, _ := nearestDocumentLocation(root, rule.Path)
|
|
primary := documentLocationPointer(location)
|
|
return []Diagnostic{{
|
|
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
|
Message: "schema version must be a string, integer, or null", Primary: primary,
|
|
}}
|
|
}
|
|
}
|
|
state := &validationState{
|
|
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
|
invalidPath: CodeVersionError, document: root,
|
|
}
|
|
validation := &Validation{state: state}
|
|
err := rule.Check(version, validation)
|
|
state.active = false
|
|
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 canonicalDiagnostics(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}
|
|
}
|