Add audited application resolution
This commit is contained in:
359
resolution.go
Normal file
359
resolution.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
const closedCallbackContextMessage = "configkit: callback context is no longer active"
|
||||
|
||||
type resolutionState struct {
|
||||
active bool
|
||||
schema *schemaDescriptor
|
||||
presence map[string]inputPresence
|
||||
document *document.Node
|
||||
records map[string]*resolutionRecord
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
type resolutionRecord struct {
|
||||
path Path
|
||||
kind ChangeKind
|
||||
reasons []string
|
||||
dependencies []Path
|
||||
helperExpected *document.Node
|
||||
}
|
||||
|
||||
type resolutionSnapshot struct {
|
||||
change Change
|
||||
before *document.Node
|
||||
after *document.Node
|
||||
}
|
||||
|
||||
type resolutionResult struct {
|
||||
final *document.Node
|
||||
deltas []documentDelta
|
||||
snapshots []resolutionSnapshot
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
// IsExplicit reports whether surviving input explicitly defined path.
|
||||
func (r *Resolution) IsExplicit(path Path) bool {
|
||||
state := r.activeState()
|
||||
if !state.validPath(path) {
|
||||
return false
|
||||
}
|
||||
_, explicit := state.presence[path.String()]
|
||||
return explicit
|
||||
}
|
||||
|
||||
// Record declares the intent for a resolver mutation. A container path covers
|
||||
// mutations to its descendants.
|
||||
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
|
||||
state := r.activeState()
|
||||
state.addRecord(path, kind, reason, dependencies, nil)
|
||||
}
|
||||
|
||||
// Error reports a safe, value-free resolution error at path.
|
||||
func (r *Resolution) Error(path Path, message string, related ...Path) {
|
||||
r.activeState().report(SeverityError, path, message, related)
|
||||
}
|
||||
|
||||
// Warning reports a safe, value-free resolution warning at path.
|
||||
func (r *Resolution) Warning(path Path, message string, related ...Path) {
|
||||
r.activeState().report(SeverityWarning, path, message, related)
|
||||
}
|
||||
|
||||
// Default assigns and records value only when path was absent from surviving
|
||||
// input. Static defaults are not considered explicit.
|
||||
func Default[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
|
||||
state := resolution.activeState()
|
||||
descriptor, ok := state.helperDescriptor(path, target)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, explicit := state.presence[path.String()]; explicit {
|
||||
return
|
||||
}
|
||||
*target = value
|
||||
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state.addRecord(path, ChangeDerivedDefault, reason, dependencies, expected)
|
||||
}
|
||||
|
||||
// Normalize assigns and records value regardless of input presence.
|
||||
func Normalize[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
|
||||
state := resolution.activeState()
|
||||
descriptor, ok := state.helperDescriptor(path, target)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
*target = value
|
||||
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state.addRecord(path, ChangeNormalization, reason, dependencies, expected)
|
||||
}
|
||||
|
||||
func (r *Resolution) activeState() *resolutionState {
|
||||
if r == nil || r.state == nil || !r.state.active {
|
||||
panic(closedCallbackContextMessage)
|
||||
}
|
||||
return r.state
|
||||
}
|
||||
|
||||
func (state *resolutionState) validPath(path Path) bool {
|
||||
if _, ok := resolveSchemaPath(state.schema, path); ok {
|
||||
return true
|
||||
}
|
||||
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution path is not valid for the configuration schema")
|
||||
return false
|
||||
}
|
||||
|
||||
func (state *resolutionState) helperDescriptor(path Path, target any) (*typeDescriptor, bool) {
|
||||
resolved, ok := resolveSchemaPath(state.schema, path)
|
||||
if !ok {
|
||||
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution helper path is not valid for the configuration schema")
|
||||
return nil, false
|
||||
}
|
||||
value := reflect.ValueOf(target)
|
||||
if !value.IsValid() || value.Kind() != reflect.Pointer || value.IsNil() {
|
||||
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target must be a non-nil pointer")
|
||||
return nil, false
|
||||
}
|
||||
if resolved.descriptor.kind != descriptorDynamic && value.Elem().Type() != resolved.descriptor.typeOf {
|
||||
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target type does not match its configuration path")
|
||||
return nil, false
|
||||
}
|
||||
return resolved.descriptor, true
|
||||
}
|
||||
|
||||
func (state *resolutionState) projectHelperValue(path Path, descriptor *typeDescriptor, value reflect.Value) (*document.Node, bool) {
|
||||
if descriptor.kind == descriptorDynamic {
|
||||
wrapped := reflect.New(descriptor.typeOf).Elem()
|
||||
if value.IsValid() {
|
||||
wrapped.Set(value)
|
||||
}
|
||||
value = wrapped
|
||||
}
|
||||
operation := valueOperation{
|
||||
schema: state.schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool),
|
||||
}
|
||||
node, issue := operation.project(value, descriptor, path, 0)
|
||||
if issue != nil {
|
||||
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "resolution helper value is not a valid configuration value")
|
||||
return nil, false
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
|
||||
func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason string, dependencies []Path, expected *document.Node) {
|
||||
if !state.validPath(path) {
|
||||
return
|
||||
}
|
||||
if kind != ChangeDerivedDefault && kind != ChangeNormalization {
|
||||
state.addDiagnostic(CodeResolveInvalidKind, path, "resolution record has an invalid change kind")
|
||||
return
|
||||
}
|
||||
if reason == "" {
|
||||
state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason")
|
||||
return
|
||||
}
|
||||
for _, dependency := range dependencies {
|
||||
if !state.validPath(dependency) {
|
||||
return
|
||||
}
|
||||
}
|
||||
pointer := path.String()
|
||||
record := state.records[pointer]
|
||||
if record == nil {
|
||||
record = &resolutionRecord{path: path.clone(), kind: kind}
|
||||
state.records[pointer] = record
|
||||
}
|
||||
if kind == ChangeNormalization {
|
||||
record.kind = ChangeNormalization
|
||||
}
|
||||
record.reasons = appendUniqueString(record.reasons, reason)
|
||||
for _, dependency := range dependencies {
|
||||
record.dependencies = appendUniquePath(record.dependencies, dependency)
|
||||
}
|
||||
if expected != nil {
|
||||
record.helperExpected = document.Clone(expected)
|
||||
}
|
||||
}
|
||||
|
||||
func (state *resolutionState) report(severity Severity, path Path, message string, related []Path) {
|
||||
code := CodeResolveError
|
||||
if severity == SeverityWarning {
|
||||
code = CodeResolveWarning
|
||||
}
|
||||
if !state.validPath(path) {
|
||||
return
|
||||
}
|
||||
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
|
||||
if location, ok := nearestDocumentLocation(state.document, path); ok {
|
||||
diagnostic.Primary = documentLocationPointer(location)
|
||||
}
|
||||
for _, relatedPath := range related {
|
||||
if !state.validPath(relatedPath) {
|
||||
continue
|
||||
}
|
||||
if location, ok := nearestDocumentLocation(state.document, relatedPath); ok {
|
||||
diagnostic.Related = append(diagnostic.Related, Location{
|
||||
SourceID: location.SourceID, Line: location.Line, Column: location.Column,
|
||||
})
|
||||
}
|
||||
}
|
||||
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||
}
|
||||
|
||||
func (state *resolutionState) addDiagnostic(code Code, path Path, message string) {
|
||||
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
|
||||
if location, ok := nearestDocumentLocation(state.document, path); ok {
|
||||
diagnostic.Primary = documentLocationPointer(location)
|
||||
}
|
||||
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||
}
|
||||
|
||||
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
|
||||
state := &resolutionState{
|
||||
active: true, schema: schema, presence: decoded.presence, document: input,
|
||||
records: make(map[string]*resolutionRecord),
|
||||
}
|
||||
context := &Resolution{state: state}
|
||||
err := resolver(configuration, context)
|
||||
state.active = false
|
||||
if err != nil && !hasErrors(state.diagnostics) {
|
||||
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
|
||||
}
|
||||
if hasErrors(state.diagnostics) {
|
||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||
}
|
||||
|
||||
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
|
||||
if issue != nil {
|
||||
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||
}
|
||||
deltas := compareDocumentTrees(decoded.final, after)
|
||||
ordered := orderedResolutionRecords(state.records)
|
||||
for _, delta := range deltas {
|
||||
if coveringResolutionRecord(ordered, delta.path) == nil {
|
||||
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
|
||||
}
|
||||
}
|
||||
for _, record := range ordered {
|
||||
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
|
||||
afterNode, afterPresent := documentAtPath(after, record.path)
|
||||
if !beforePresent && !afterPresent {
|
||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||
continue
|
||||
}
|
||||
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
|
||||
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
||||
}
|
||||
_ = beforeNode
|
||||
}
|
||||
if hasErrors(state.diagnostics) {
|
||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||
}
|
||||
|
||||
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
||||
for _, record := range ordered {
|
||||
beforeNode, _ := documentAtPath(decoded.final, record.path)
|
||||
afterNode, _ := documentAtPath(after, record.path)
|
||||
snapshots = append(snapshots, resolutionSnapshot{
|
||||
change: Change{
|
||||
Path: record.path.clone(),
|
||||
Origin: Origin{Layer: LayerResolution, Operation: record.kind},
|
||||
Reasons: append([]string(nil), record.reasons...),
|
||||
Dependencies: clonePaths(record.dependencies),
|
||||
},
|
||||
before: document.Clone(beforeNode), after: document.Clone(afterNode),
|
||||
})
|
||||
}
|
||||
return resolutionResult{
|
||||
final: after, deltas: deltas, snapshots: snapshots,
|
||||
diagnostics: canonicalDiagnostics(state.diagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
|
||||
ordered := make([]*resolutionRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
ordered = append(ordered, record)
|
||||
}
|
||||
slices.SortFunc(ordered, func(left, right *resolutionRecord) int {
|
||||
return strings.Compare(left.path.String(), right.path.String())
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord {
|
||||
var covering *resolutionRecord
|
||||
for _, record := range records {
|
||||
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) {
|
||||
covering = record
|
||||
}
|
||||
}
|
||||
return covering
|
||||
}
|
||||
|
||||
func pathContains(container, descendant Path) bool {
|
||||
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
|
||||
return false
|
||||
}
|
||||
if container.String() == "" {
|
||||
return true
|
||||
}
|
||||
prefix := container.String()
|
||||
value := descendant.String()
|
||||
return value == prefix || strings.HasPrefix(value, prefix+"/")
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
if !slices.Contains(values, value) {
|
||||
return append(values, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func appendUniquePath(paths []Path, path Path) []Path {
|
||||
for _, existing := range paths {
|
||||
if existing.String() == path.String() {
|
||||
return paths
|
||||
}
|
||||
}
|
||||
return append(paths, path.clone())
|
||||
}
|
||||
|
||||
func clonePaths(paths []Path) []Path {
|
||||
if paths == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]Path, len(paths))
|
||||
for index, path := range paths {
|
||||
cloned[index] = path.clone()
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||
if snapshots == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]resolutionSnapshot, len(snapshots))
|
||||
for index, snapshot := range snapshots {
|
||||
cloned[index] = resolutionSnapshot{
|
||||
change: cloneChange(snapshot.change),
|
||||
before: document.Clone(snapshot.before), after: document.Clone(snapshot.after),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
Reference in New Issue
Block a user