package configkit import ( "os" "reflect" "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) // Definition contains reusable application rules for a configuration type. // Secret is a pure predicate that can add sensitivity to semantic paths. // Definitions should be treated as read-only after being shared. type Definition[T any] struct { Defaults func() T Resolve func(*T, *Resolution) error Validate func(*T, *Validation) Version *VersionRule Secret func(Path) bool } // Options contains the application definition and per-load input selections. // Imports enables explicit path and glob imports; Profile selects one fixed profile // overlay; Overrides contains only values explicitly supplied by an application CLI. type Options[T any] struct { Definition Definition[T] Imports bool Profile string Filesystem *FilesystemContext Overrides []Override } // FilesystemContext supplies filesystem roots for imports and profiles. type FilesystemContext struct { BaseDir string Root string } // Override describes one explicitly supplied application override. Value uses // parser-neutral built-in scalars, nil, []any, or map[string]any; Flag is a safe // display name and must not contain a raw argument or value. type Override struct { Path Path Value any Flag string } // Resolution exposes surviving-input presence and records audited application // resolution changes. A resolver must not retain or concurrently use the context. type Resolution struct { state *resolutionState } // VersionRule declares the input path and application checker for a schema version. type VersionRule struct { Path Path Check func(Version, *Validation) error } // Version preserves the absent, null, and string states of a schema-version input. type Version struct { Present bool Null bool Text string } // Result is a successful independently owned configuration load. type Result[T any] struct { Config T metadata Metadata sources []SourceInfo diagnostics []Diagnostic baseline *document.Node input *document.Node inputOrigins map[string]Origin final *document.Node resolution []resolutionSnapshot applications []historyApplication secrets map[string]bool secretReferences map[string]string } // Metadata returns detached effective presence, origin, and history records. func (r *Result[T]) Metadata() Metadata { if r == nil { return Metadata{} } return cloneMetadata(r.metadata) } // Sources returns a detached list of sources used by the load. func (r *Result[T]) Sources() []SourceInfo { if r == nil { return nil } return cloneSourceInfos(r.sources) } // Diagnostics returns detached nonfatal diagnostics from the load. func (r *Result[T]) Diagnostics() []Diagnostic { if r == nil { return nil } return cloneDiagnostics(r.diagnostics) } // Load parses and strictly decodes zero or one selected source, explicitly enabled // imports, and one selected profile. It accepts at most one Options value and // returns no result when any error is reported. func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { return loadWithSecretLookup(source, os.LookupEnv, supplied...) } func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { accumulator := diagnosticAccumulator{} options, diagnostics := selectOptions(source, supplied) if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits()) if len(diagnostics) != 0 { if parsed.source != nil { accumulator.sources = append(accumulator.sources, *parsed.source) } accumulator.add(diagnostics...) return nil, accumulator.loadError() } inputDocument := parsed.document var composition *composedDocument if options.Imports { composition, accumulator.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits()) if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } inputDocument = composition.root } else { if parsed.source != nil { accumulator.sources = append(accumulator.sources, *parsed.source) } if options.Filesystem != nil && !activeProfile(options.Profile) { if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil { accumulator.add(*diagnostic) return nil, accumulator.loadError() } } } if !options.Imports && inputDocument != nil { directiveDiagnostics := disabledDirectiveDiagnostics(inputDocument, Path{}) if len(directiveDiagnostics) != 0 { accumulator.add(directiveDiagnostics...) return nil, accumulator.loadError() } } schema, schemaIssue := buildSchema(reflect.TypeFor[T]()) if schemaIssue != nil { accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) return nil, accumulator.loadError() } versionDiagnostics := checkVersion(options.Definition.Version, inputDocument) accumulator.add(versionDiagnostics...) if hasErrors(versionDiagnostics) { return nil, accumulator.loadError() } if activeProfile(options.Profile) { if composition == nil { composition, diagnostics = composeDocuments(inputDocument) if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } composition.inputBytes = parsed.bytes composition.inputNodes = countInputNodes(parsed.document) } composition, accumulator.sources, diagnostics = loadProfileDocument( source, options.Filesystem, parsed, composition, accumulator.sources, options.Profile, options.Definition.Version, defaultImportLimits(), ) if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } inputDocument = composition.root } if len(options.Overrides) != 0 { if composition == nil { if inputDocument == nil { composition = &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)} } else { composition, diagnostics = composeDocuments(inputDocument) if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } composition.inputBytes = parsed.bytes composition.inputNodes = countInputNodes(inputDocument) } } composition, accumulator.sources, diagnostics = applyOverrides( composition, accumulator.sources, options.Overrides, options.Definition.Version, defaultImportLimits(), ) if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } inputDocument = composition.root } defaults := reflect.Zero(reflect.TypeFor[T]()) if options.Definition.Defaults != nil { defaults = reflect.ValueOf(options.Definition.Defaults()) } decoded, decodeIssues := decodeConfiguration(schema, defaults, inputDocument) if len(decodeIssues) != 0 { for _, issue := range decodeIssues { accumulator.add(diagnosticFromDecodeIssue(issue)) } return nil, accumulator.loadError() } if composition != nil { applyCompositionOrigins(&decoded, composition.origins) } configuration := decoded.value.Interface().(T) metadata := metadataFromDecoded(decoded) if composition != nil { applyOverlayMetadata(&metadata, composition.overlays) } final := decoded.final var resolutionSnapshots []resolutionSnapshot if options.Definition.Resolve != nil { resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, inputDocument) accumulator.add(resolved.diagnostics...) if hasErrors(resolved.diagnostics) { return nil, accumulator.loadError() } final = resolved.final resolutionSnapshots = resolved.snapshots applyResolutionMetadata(&metadata, resolved) } applications := []historyApplication(nil) if composition != nil { applications = cloneHistoryApplications(composition.applications) } else if inputDocument != nil { applications = []historyApplication{{layer: LayerBase, value: inputDocument}} } if !historyNodesWithinLimit(decoded.baseline, decoded.final, final, applications, resolutionSnapshots) { accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"}) return nil, accumulator.loadError() } // Resolver assignments may borrow application-owned mutable values. Detach // before secret resolution mutates reference fields or validation sees them. detached, ownershipIssue := cloneConfiguration(schema, reflect.ValueOf(configuration), defaultProjectionLimits()) if ownershipIssue != nil { accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue))) return nil, accumulator.loadError() } configuration = detached.Interface().(T) secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup) accumulator.add(secretResolution.diagnostics...) if hasErrors(secretResolution.diagnostics) { return nil, accumulator.loadError() } if options.Definition.Validate != nil { validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups) accumulator.add(validationDiagnostics...) if hasErrors(validationDiagnostics) { return nil, accumulator.loadError() } } // A read-only validator can replace a value with an equal borrowed object. // Projected mutation checks cannot detect that alias, so detach at publication. published, ownershipIssue := cloneConfigurationState(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), true) if ownershipIssue != nil { accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue))) return nil, accumulator.loadError() } configuration = published.Interface().(T) finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) return &Result[T]{ Config: configuration, metadata: metadata, sources: cloneSourceInfos(accumulator.sources), diagnostics: canonicalDiagnostics(accumulator.diagnostics), baseline: decoded.baseline, input: decoded.final, inputOrigins: cloneOriginIndex(decoded.origins), final: final, resolution: resolutionSnapshots, applications: applications, secrets: secrets, secretReferences: cloneSecretReferences(secretResolution.references), }, nil } func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) { if len(supplied) > 1 { return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")} } var options Options[T] if len(supplied) == 1 { options = supplied[0] } var diagnostics []Diagnostic definition := options.Definition if !validProfileName(options.Profile) { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen")) } if options.Filesystem != nil { diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...) } if source.kind == sourceNone && options.Imports { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports")) } if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile")) } if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context")) } if definition.Version != nil { if !definition.Version.Path.valid() { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid")) } if definition.Version.Check == nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker")) } } return options, canonicalDiagnostics(diagnostics) } func optionDiagnostic(code Code, message string) Diagnostic { return Diagnostic{Code: code, Severity: SeverityError, Message: message} } func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { var diagnostics []Diagnostic if node.Kind == document.Mapping { for _, entry := range node.Mapping { childPath := path.Key(entry.Key) if entry.Key == "_configkit" { location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column} diagnostics = append(diagnostics, Diagnostic{ Code: CodeImportDisabled, Severity: SeverityError, Path: childPath, Message: "configuration directives require imports to be enabled", Primary: &location, }) } diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...) } } else if node.Kind == document.Sequence { for index, child := range node.Sequence { diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...) } } 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 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 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 documentAtPath(root *document.Node, path Path) (*document.Node, bool) { if root == nil || !path.valid() { return nil, false } current := root for _, segment := range path.segments { switch current.Kind { case document.Mapping: if segment.kind == pathSegmentIndex { return nil, false } found := false for _, entry := range current.Mapping { if entry.Key == segment.key { current = entry.Value found = true break } } if !found { return nil, false } case document.Sequence: index, ok := schemaIndex(segment) if !ok || index >= len(current.Sequence) { return nil, false } current = current.Sequence[index] default: return nil, false } } return current, true } func documentLocationPointer(location document.Location) *Location { if !locationKnown(location) { return nil } return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column} } func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message} } func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic { return Diagnostic{ Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message, Primary: documentLocationPointer(issue.location), } } func hasErrors(diagnostics []Diagnostic) bool { for _, diagnostic := range diagnostics { if diagnostic.Severity == SeverityError { return true } } return false }