package configkit import ( "reflect" "strconv" "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 { if configured == nil || !configured.valid() || !current.valid() || len(configured.segments) != len(current.segments) { return false } for i, expected := range configured.segments { actual := current.segments[i] if expected.kind == pathSegmentParsed { if actual.kind == pathSegmentIndex { if expected.key != strconv.Itoa(actual.index) { return false } } else if expected.key != actual.key { return false } continue } if expected.kind != actual.kind || expected.key != actual.key || expected.index != actual.index { return false } } return true } func documentLocationPointer(location document.Location) *Location { if !locationKnown(location) { return nil } return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column} }