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) } for _, delta := range compareDocumentTrees(before, after) { state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path)) } 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 }