28 Commits

Author SHA1 Message Date
21ae2159f7 Prepare ConfigKit v0.3.1 2026-09-21 13:27:48 +00:00
b9425c9ae2 Ignore hidden entries during secrets bootstrap 2026-09-21 13:26:01 +00:00
fbe7b61c79 Stop tracking codebase memory artifacts 2026-09-12 19:20:25 +00:00
34380f62a5 Prepare ConfigKit v0.3.0 2026-09-12 18:35:51 +00:00
f2c650b5eb Preserve closed callback failures through the facade 2026-09-12 18:33:11 +00:00
5760614834 Make macOS support best effort 2026-09-12 18:30:59 +00:00
88e86de03e Repair development guide formatting 2026-09-12 17:26:12 +00:00
c98435ce8e Restore public API reference documentation 2026-09-12 17:24:44 +00:00
6102b36adf Check resolved types at the public boundary 2026-09-12 17:23:47 +00:00
7395f54513 Preserve missing version checker validation 2026-09-12 17:20:40 +00:00
5dc7e18d83 Audit the public facade boundary 2026-09-12 16:26:44 +00:00
7f46c947dd Move facade to the root package 2026-09-12 16:21:17 +00:00
550f92d7c6 Complete facade inspection and secrets bootstrap 2026-09-12 16:19:22 +00:00
da307c9f49 Add facade lifecycle wrappers 2026-09-12 16:16:11 +00:00
b8935e77bd Separate engine loading from inspection state 2026-09-12 16:12:01 +00:00
05812a6ad3 Add public facade value wrappers 2026-09-12 16:07:38 +00:00
da02a19f2c Bind engine secret handling to facade types 2026-09-12 16:04:31 +00:00
9b4f9ac50e Add internal engine relocation candidate 2026-09-12 15:58:06 +00:00
f81def9365 Record public facade boundary 2026-09-12 15:56:11 +00:00
eb7de8e40a Plan the public facade refactor 2026-09-12 15:16:52 +00:00
28e7fc5f69 Avoid copying unused resolution history 2026-09-12 13:51:04 +00:00
715364b079 Complete resolution provenance readiness audit 2026-09-12 13:25:38 +00:00
b9e4dfac92 Demonstrate source-aware path normalization 2026-09-12 13:21:21 +00:00
cc98be8f0a Verify layered resolution provenance 2026-09-12 13:17:22 +00:00
e20a0a0998 Expose resolution input provenance 2026-09-12 13:13:07 +00:00
88f21d09c7 Prepare resolution entry provenance view 2026-09-12 13:10:48 +00:00
818d36f836 Clarify implementation discipline and provenance feature scope 2026-09-12 13:05:00 +00:00
381430a24b Plan source origins during resolution 2026-09-12 12:52:32 +00:00
108 changed files with 4816 additions and 1711 deletions

4
.gitignore vendored
View File

@@ -25,6 +25,9 @@ go.work.sum
# env file # env file
.env .env
# Local codebase-memory tooling artifacts
/.codebase-memory/
# ---> Linux # ---> Linux
*~ *~
@@ -81,4 +84,3 @@ Temporary Items
.history/ .history/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix

View File

@@ -1,2 +1,35 @@
Please review `docs/development.md` for initial orientation in this repository Please review `docs/development.md` for initial orientation in this repository
and follow its task-specific reading guide. and follow its task-specific reading guide.
## Implementation Discipline
Simplify the mechanism, not the user's choices.
**Keep implementation machinery proportionate.** Use straightforward,
idiomatic code and introduce only the machinery needed to support the requested
behavior and flexibility. Optimize for conceptual simplicity and
maintainability, not for the fewest lines, fewest options, or narrowest product
surface.
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
harden, or otherwise narrow behavior unless required by the user, an explicit
repository contract, or an unavoidable correctness constraint. Preserve
operator and caller choice where policy has not been established. If an
additional restriction appears advisable, present it as a decision rather than
silently implementing it.
A correctness constraint must follow from the requested behavior or an
established invariant, not from the convenience of the chosen implementation.
Explain the specific failure it prevents and why a less restrictive
implementation cannot satisfy the contract.
**An implementation detail is not repository policy.** Tests and documentation
must not elevate incidental behavior into a durable guarantee or restriction
unless that behavior was expressly selected as part of the contract. Existing
explicit contracts remain authoritative; changing or weakening one requires a
deliberate decision rather than being inferred from this principle.
Agent-authored plans, tests, documentation, and prior implementations are not
evidence that an additional restriction was deliberately approved. Identify the
authorizing requirement or decision; when that authority is unclear, surface
the ambiguity rather than treating repetition as approval.

View File

@@ -2,6 +2,9 @@
An opinionated Go library for applications that use YAML configuration files. An opinionated Go library for applications that use YAML configuration files.
Linux is release-validated; macOS support is best-effort. See the
[platform support policy](docs/policy/release.md#platform-support) for details.
ConfigKit's initial loader accepts one strict YAML ConfigKit's initial loader accepts one strict YAML
source (or explicit defaults-only input), optionally traverses explicit path and glob source (or explicit defaults-only input), optionally traverses explicit path and glob
imports, strictly composes their disjoint definitions, applies typed defaults, and imports, strictly composes their disjoint definitions, applies typed defaults, and

133
conversion.go Normal file
View File

@@ -0,0 +1,133 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
func fromEngineLocation(value engine.Location) Location {
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
}
func toEngineLocation(value Location) engine.Location {
return engine.Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
}
func fromEngineLocationPointer(value *engine.Location) *Location {
if value == nil {
return nil
}
result := fromEngineLocation(*value)
return &result
}
func toEngineLocationPointer(value *Location) *engine.Location {
if value == nil {
return nil
}
result := toEngineLocation(*value)
return &result
}
func fromEngineLocations(values []engine.Location) []Location {
if values == nil {
return nil
}
result := make([]Location, len(values))
for i := range values {
result[i] = fromEngineLocation(values[i])
}
return result
}
func toEngineLocations(values []Location) []engine.Location {
if values == nil {
return nil
}
result := make([]engine.Location, len(values))
for i := range values {
result[i] = toEngineLocation(values[i])
}
return result
}
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
}
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
}
func fromEngineOrigin(value engine.Origin) Origin {
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
}
func toEngineOrigin(value Origin) engine.Origin {
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
}
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
}
func toEngineDiagnostic(value Diagnostic) engine.Diagnostic {
return engine.Diagnostic{Code: engine.Code(value.Code), Severity: engine.Severity(value.Severity), Path: toEnginePath(value.Path), Message: value.Message, Primary: toEngineLocationPointer(value.Primary), Related: toEngineLocations(value.Related)}
}
func toEngineDiagnostics(values []Diagnostic) []engine.Diagnostic {
if values == nil {
return nil
}
result := make([]engine.Diagnostic, len(values))
for i := range values {
result[i] = toEngineDiagnostic(values[i])
}
return result
}
func fromEngineFieldInfo(value engine.FieldInfo) FieldInfo {
return FieldInfo{Explicit: value.Explicit, Null: value.Null, Origin: fromEngineOrigin(value.Origin)}
}
func fromEngineChange(value engine.Change) Change {
result := Change{Path: fromEnginePath(value.Path), Origin: fromEngineOrigin(value.Origin), Reasons: append([]string(nil), value.Reasons...)}
if value.Previous != nil {
result.Previous = make([]Origin, len(value.Previous))
for i := range value.Previous {
result.Previous[i] = fromEngineOrigin(value.Previous[i])
}
}
if value.Dependencies != nil {
result.Dependencies = make([]Path, len(value.Dependencies))
for i := range value.Dependencies {
result.Dependencies[i] = fromEnginePath(value.Dependencies[i])
}
}
return result
}
func fromEngineValueSnapshot(value engine.ValueSnapshot) ValueSnapshot {
return ValueSnapshot{Present: value.Present, Redacted: value.Redacted, YAML: value.YAML}
}
func fromEngineValueChange(value engine.ValueChange) ValueChange {
result := ValueChange{
Before: fromEngineValueSnapshot(value.Before),
After: fromEngineValueSnapshot(value.After),
Origin: fromEngineOrigin(value.Origin),
Reasons: append([]string(nil), value.Reasons...),
}
if value.Dependencies != nil {
result.Dependencies = make([]Path, len(value.Dependencies))
for index := range value.Dependencies {
result.Dependencies[index] = fromEnginePath(value.Dependencies[index])
}
}
return result
}
func fromEngineExplanation(value engine.Explanation) Explanation {
result := Explanation{
Path: fromEnginePath(value.Path),
Effective: fromEngineValueSnapshot(value.Effective),
Origin: fromEngineOrigin(value.Origin),
}
if value.History != nil {
result.History = make([]ValueChange, len(value.History))
for index := range value.History {
result.History[index] = fromEngineValueChange(value.History[index])
}
}
if value.SecretReference != nil {
result.SecretReference = &SecretReferenceInfo{Name: value.SecretReference.Name}
}
return result
}
func fromEngineSecretFileResult(value engine.SecretFileResult) SecretFileResult {
return SecretFileResult{Name: value.Name, Path: value.Path, Status: SecretFileStatus(value.Status), ExistingEmpty: value.ExistingEmpty}
}

View File

@@ -1,248 +1,57 @@
package configkit package configkit
import ( import (
"cmp"
"fmt"
"io" "io"
"slices"
"strconv" "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
"strings"
"unicode"
) )
// LoadError reports safe structured diagnostics for a failed configuration load. // LoadError reports safe structured diagnostics for a failed configuration load.
// It intentionally does not expose or unwrap parser and application errors. // It intentionally does not expose or unwrap parser and application errors.
type LoadError struct { type LoadError struct{ value *engine.LoadError }
diagnostics []Diagnostic
sources []SourceInfo
}
// Error implements error without rendering diagnostic messages or input values. // Error implements error without rendering diagnostic messages or input values.
func (e *LoadError) Error() string { func (e *LoadError) Error() string {
if e == nil { if e == nil || e.value == nil {
return "configkit: configuration load failed" return "configkit: configuration load failed"
} }
count := 0 return e.value.Error()
for _, diagnostic := range e.diagnostics {
if diagnostic.Severity == SeverityError {
count++
}
}
if count == 1 {
return "configkit: configuration load failed with 1 error"
}
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
} }
// Diagnostics returns a deep copy of the error's canonical diagnostics. // Diagnostics returns a deep copy of the error's canonical diagnostics.
func (e *LoadError) Diagnostics() []Diagnostic { func (e *LoadError) Diagnostics() []Diagnostic {
if e == nil { if e == nil || e.value == nil {
return nil return nil
} }
return cloneDiagnostics(e.diagnostics) values := e.value.Diagnostics()
if values == nil {
return nil
}
result := make([]Diagnostic, len(values))
for i := range values {
result[i] = fromEngineDiagnostic(values[i])
}
return result
} }
// Sources returns a deep copy of sources discovered before loading failed. // Sources returns a deep copy of sources discovered before loading failed.
func (e *LoadError) Sources() []SourceInfo { func (e *LoadError) Sources() []SourceInfo {
if e == nil { if e == nil || e.value == nil {
return nil return nil
} }
return cloneSourceInfos(e.sources) values := e.value.Sources()
if values == nil {
return nil
}
result := make([]SourceInfo, len(values))
for i := range values {
result[i] = fromEngineSourceInfo(values[i])
}
return result
} }
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal // WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes. // control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
canonical := canonicalDiagnostics(diagnostics) return engine.WriteDiagnostics(output, toEngineDiagnostics(diagnostics))
for _, diagnostic := range canonical {
if err := writeDiagnostic(output, diagnostic); err != nil {
return err
}
}
return nil
}
type diagnosticAccumulator struct {
diagnostics []Diagnostic
sources []SourceInfo
}
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
for _, diagnostic := range diagnostics {
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
}
}
func (a *diagnosticAccumulator) loadError() *LoadError {
return &LoadError{
diagnostics: canonicalDiagnostics(a.diagnostics),
sources: cloneSourceInfos(a.sources),
}
}
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
canonical := cloneDiagnostics(diagnostics)
for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation)
}
slices.SortFunc(canonical, compareDiagnostic)
return canonical
}
func compareDiagnostic(left, right Diagnostic) int {
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
return order
}
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
return order
}
if order := cmp.Compare(left.Code, right.Code); order != 0 {
return order
}
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
return order
}
if order := cmp.Compare(left.Message, right.Message); order != 0 {
return order
}
return compareLocations(left.Related, right.Related)
}
func compareOptionalLocation(left, right *Location) int {
if left == nil && right == nil {
return 0
}
if left == nil {
return -1
}
if right == nil {
return 1
}
return compareLocation(*left, *right)
}
func compareLocations(left, right []Location) int {
for i := 0; i < min(len(left), len(right)); i++ {
if order := compareLocation(left[i], right[i]); order != 0 {
return order
}
}
return cmp.Compare(len(left), len(right))
}
func compareLocation(left, right Location) int {
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
return order
}
if order := cmp.Compare(left.Line, right.Line); order != 0 {
return order
}
return cmp.Compare(left.Column, right.Column)
}
func compareSourceID(left, right string) int {
leftOrder, leftOK := sourceSequence(left)
rightOrder, rightOK := sourceSequence(right)
if leftOK && rightOK {
return cmp.Compare(leftOrder, rightOrder)
}
return cmp.Compare(left, right)
}
func sourceSequence(sourceID string) (int, bool) {
const prefix = "source-"
if !strings.HasPrefix(sourceID, prefix) {
return 0, false
}
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
if err != nil || value < 1 {
return 0, false
}
return value, true
}
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
severity := diagnostic.Severity
if severity == "" {
severity = SeverityError
}
path := diagnostic.Path.String()
if path == "" {
path = "<root>"
}
var b strings.Builder
b.WriteString(escapeControls(string(severity)))
if diagnostic.Code != "" {
b.WriteString(" [")
b.WriteString(escapeControls(string(diagnostic.Code)))
b.WriteByte(']')
}
b.WriteByte(' ')
b.WriteString(escapeControls(path))
if diagnostic.Primary != nil {
b.WriteString(" (")
writeLocationText(&b, *diagnostic.Primary)
b.WriteByte(')')
}
if diagnostic.Message != "" {
b.WriteString(": ")
b.WriteString(escapeControls(diagnostic.Message))
}
b.WriteByte('\n')
for _, related := range diagnostic.Related {
b.WriteString(" related: ")
writeLocationText(&b, related)
b.WriteByte('\n')
}
return writeString(output, b.String())
}
func writeLocationText(output *strings.Builder, location Location) {
label := location.SourceID
if label == "" {
label = "<synthetic>"
}
output.WriteString(escapeControls(label))
if location.Line > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Line))
if location.Column > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Column))
}
}
}
func writeString(output io.Writer, value string) error {
written, err := io.WriteString(output, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func escapeControls(value string) string {
var b strings.Builder
for _, r := range value {
if unicode.IsControl(r) {
quoted := strconv.QuoteRune(r)
b.WriteString(quoted[1 : len(quoted)-1])
continue
}
b.WriteRune(r)
}
return b.String()
}
func hasErrors(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == SeverityError {
return true
}
}
return false
} }

11
doc.go
View File

@@ -1,10 +1,9 @@
// Package configkit provides deterministic, application-owned configuration // Package configkit provides deterministic, application-owned configuration
// loading for Go programs. // loading for Go programs.
// //
// Load currently supports one file, byte, reader, or explicit no-input source with // Load supports file, byte, reader, and explicit no-input sources with strict YAML,
// strict YAML, typed defaults, schema-version checks, detached metadata, safe // typed defaults, imports, profiles, CLI overrides, schema-version checks, detached
// diagnostics, recorded application resolution, read-only semantic validation, and // metadata, recorded application resolution, typed secret references, safe effective
// structured load errors. Composition, profiles, overrides, secrets, and effective // rendering, read-only semantic validation, and structured load errors. The
// rendering are not available yet. The configuration contract is documented in // configuration contract is documented in docs/config.md.
// docs/config.md.
package configkit package configkit

View File

@@ -0,0 +1,61 @@
# ADR 0006: Keep the public facade root-owned
## Status
Accepted and implemented on 2026-09-12.
## Date
2026-09-12
## Context
The root `configkit` package formerly combined the supported consumer API with
loading, decoding, inspection, and rendering machinery. That made ordinary
consumer navigation harder and risked exposing implementation details through
future public declarations. Moving files alone would not preserve the identity,
method sets, callbacks, ownership rules, or reflection-sensitive behavior of the
root's public types.
## Decision
Keep `configkit` as the sole supported consumer import path and keep all public
named types defined there. Lifecycle machinery lives in a cohesive
`internal/engine`; the root facade holds private engine state and forwards
deliberately selected methods. The dependency direction is root to engine and
its supporting internal packages. Internal production packages do not import the
root package.
The facade will use root-owned wrappers and explicit private conversions. It
will not use public aliases to engine types, anonymous embedding that promotes
engine methods, public engine types in signatures or fields, or public bridge
methods. Existing document and YAML boundaries remain intact.
The migration used a temporary internal candidate facade while the unchanged root
remained the only production entry point. That duplication was a bounded
relocation mechanism, not a second supported package, feature path, or runtime
selection mechanism; the final cutover removed it and the old root implementation.
The [architecture policy](../policy/architecture.md#public-api-and-dependency-direction)
owns the binding boundary. The [component overview](../internal/overview.md)
describes the implemented layout; the [development guide](../development.md)
locates the completed roadmap and implementation history.
## Alternatives considered
- Alias root types to engine types: reduces forwarding, but makes the engine's
exported method set and type ownership part of the consumer API.
- Keep all machinery in the root package: avoids migration work, but leaves the
public boundary difficult to navigate and easy to expand accidentally.
- Introduce a generic adapter or registration framework: could reduce some
handwritten conversions, but adds machinery and obscures the ownership rules
this boundary needs to preserve.
## Consequences
The refactor carries explicit forwarding and conversion work, especially for
callbacks, detached records, source ownership, frozen inspection, and the
exact-type secret bridge. Public compatibility must be verified by behavior and
external-package evidence rather than inferred from package names. Internal
exports remain implementation surfaces, and the completed architecture will
have one engine and one root facade.

View File

@@ -293,10 +293,13 @@ operation once before starting concurrent work. The selected path may itself be
symlink; ConfigKit resolves it to an existing canonical directory before examining symlink; ConfigKit resolves it to an existing canonical directory before examining
its immediate entries. its immediate entries.
The bootstrap enumerates at most 1,024 entries and sorts them by filename. Every The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries.
entry must have an ASCII environment-variable name and be a regular, nonsymlink Entries whose names begin with `.` (files, directories, or symlinks, such as `.git`
file. Metadata for all entries is validated before file content is read or process and `.gitignore`) are ignored before name or metadata validation, are never opened,
state is changed. Existing environment variables win, including variables whose and are omitted from the report. Remaining entries are sorted by filename; each
must have an ASCII environment-variable name and be a regular, nonsymlink
file. Metadata for all remaining entries is validated before file content is read
or process state is changed. Existing environment variables win, including variables whose
value is empty; their files are not opened. Each eligible file is limited to 1 MiB, value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
@@ -326,6 +329,27 @@ receives the working configuration and a per-load `Resolution` context. The cont
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and `IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
null are never confused with absence or static defaults. null are never confused with absence or static defaults.
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
decoded entry state, before application resolution runs. It includes retained static
defaults, preserves explicit null, and uses the same exact semantic path matching as
`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return
false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every
found entry path. Resolution changes, additions, removals, and moves do not alter
this entry view.
`Resolution.Source(id)` returns a detached record for an already acquired source in
the same load: the main source, imports, selected profile, and supplied CLI records
where applicable. Empty and unknown IDs return false. It never reloads input, reads
the filesystem, resolves symlinks, or looks up credentials. A source path exists
only when the acquired source has one; defaults and byte, reader, and CLI sources do
not acquire an invented file path. Applications select a base directory from exact
origin locations and these source records according to their own policy.
Both queries are callback-scoped like the rest of `Resolution`: nil, zero, or
retained contexts panic with the fixed closed-context failure outside the callback.
Returned field and source records are detached and remain usable after callback
exit; changing them cannot affect later queries, results, or another load.
`Default` assigns and records a derived value only when its path is not explicit. `Default` assigns and records a derived value only when its path is not explicit.
`Normalize` always assigns and records. For transformations performed directly by `Normalize` always assigns and records. For transformations performed directly by
application code, `Resolution.Record` declares a derived-default or normalization application code, `Resolution.Record` declares a derived-default or normalization

View File

@@ -56,6 +56,24 @@ validate keys and ranges and convert only at their runtime boundary. Neither sha
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
conversion API. conversion API.
## Source-aware application path policy
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
path-bearing example. Its application resolver uses `Resolution.InputInfo` and
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
supplied invocation directory, retained defaults use the canonical main source,
and a file-authored scalar uses its one declaring source. Absolute paths remain
unchanged. Its executable test covers the matching effective YAML and explanation
history, source-file ownership, and a symlink opened through an alias; canonical
target-file directories, rather than the alias directory or process working
directory, are the selected bases.
The selected example policy leaves a relative value unchanged and emits a warning
when a CLI base is absent or file provenance is missing or composite. That is an
application choice, not a ConfigKit fallback: consumers can instead reject it or
choose another explicit base. The example intentionally performs neither target
existence checks nor confinement checks.
## Intentional migration changes ## Intentional migration changes
The examples preserve domain meaning while adopting one configuration convention: The examples preserve domain meaning while adopting one configuration convention:

View File

@@ -30,6 +30,7 @@ Accepted decisions:
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md) - [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md) - [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md) - [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
- [Keep the public facade root-owned](adr/0006-public-facade.md)
For cross-cutting changes, follow every applicable row. Completed implementation For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -46,6 +47,13 @@ The completed string-and-integer schema-version implementation and remediation
records are preserved in Git history at `244104f`. The records are preserved in Git history at `244104f`. The
[configuration contract](config.md#application-schema-versions) owns current behavior. [configuration contract](config.md#application-schema-versions) owns current behavior.
The completed source-origins and public-facade roadmaps, implementation plan, and
resolved review findings are preserved in Git history at `5760614`, for example
`git show 5760614:docs/roadmap/remediation.md`. The earlier source-origins staged
plan remains at `28e7fc5:docs/roadmap/implementation.md`. Current resolution
behavior belongs to the [configuration contract](config.md#recorded-application-resolution),
and current facade ownership belongs to the [component overview](internal/overview.md).
## Baseline validation ## Baseline validation
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
@@ -91,15 +99,15 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command | | Task | Behavioral test owners | Focused command |
| --- | --- | --- | | --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../parse_test.go) | `go test ./internal/yaml` and `go test . -run '^TestParseSource'` | | YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../source_test.go), [FIFO](../source_fifo_test.go), [imports](../imports_test.go), [globs](../glob_test.go) | `go test . -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | | File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Composition, profiles, CLI precedence | [Composition](../compose_test.go), [overlay](../overlay_test.go), [profiles](../profile_test.go), [overrides](../override_test.go) | `go test . -run 'Compose\|Overlay\|Profile\|Override'` | | Composition, profiles, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [profiles](../internal/engine/profile_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Profile\|Override'` |
| Schema, defaults, projection, cloning | [Schema](../schema_test.go), [decoding](../decode_test.go), [projection](../projection_test.go) | `go test . -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` | | Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../load_test.go), [resolution](../resolution_test.go), [validation](../validation_test.go), [ownership](../callback_ownership_test.go), [independence](../independence_test.go) | `go test . -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../path_test.go), [metadata](../metadata_test.go), [provenance](../provenance_test.go) | `go test . -run 'Path\|Metadata\|Provenance\|Queries'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Secrets, bootstrap, redaction, explanation | [References](../secret_reference_test.go), [bootstrap](../secrets_test.go), [rendering](../render_test.go), [explanation](../explain_test.go) | `go test . -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` | | Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` | | Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go) | `go test . -run '^(TestLocalDocumentationLinks\|TestDependencyImportBoundary)$'` | | Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
| Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` | | Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` |
## Fuzz validation ## Fuzz validation
@@ -108,7 +116,7 @@ Run the bounded fuzz targets independently from the repository root. These exact
commands keep each campaign short and constrain concurrent workers: commands keep each campaign short and constrain concurrent workers:
```sh ```sh
go test . -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2 go test ./internal/engine -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2 go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
go test . -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2 go test ./internal/engine -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
``` ```

View File

@@ -1,89 +1,39 @@
# Internal Component Overview # Internal Component Overview
ConfigKit exposes the root `configkit` package and keeps its document and YAML ConfigKit exposes one supported root `configkit` package. The root owns public
representations private. The implemented components and their owners are: named types, declaration comments, constructors, and explicit conversion/wrapper
code. It delegates loading and frozen inspection to `internal/engine` through
private state; public signatures and fields do not expose engine types.
- immutable semantic `Path` construction and strict JSON Pointer parsing; Implemented component owners:
- detached effective presence/origin and chronological history queries in
`metadata.go`, with provenance and diagnostic record types;
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
- opaque file, byte, reader, and no-input source construction in `source.go`,
with shared regular-file checks and bounded read mechanics in `source_file.go`;
- a parser-independent ordered tree in `internal/document`, with root-private
semantic lookup, indexing, and value traversal in `document_walk.go`;
- the sole upstream YAML integration in `internal/yaml`, which translates validated
input into that owned tree and exposes no parser types outside its boundary;
`parse.go` owns shared translation into detached, ordered public diagnostics;
- a private strict composer in `compose.go` that joins only disjoint document-tree
definitions, retains ordered claims and composite mapping origins, reports every
conflicting location, and never aliases an input tree;
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
with displacement records in `overlay.go`; overlay records retain origins,
while value explanation uses separate history applications;
- CLI override validation/preparation in `override.go`, parser-neutral values in
`override_value.go`, and synthetic-source application in `override_apply.go`,
sharing overlay, source, byte, node, and depth policies;
- a per-operation private reflection descriptor graph in `schema.go` for supported
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
- descriptor-driven canonical projection in `projection.go`, detached typed
cloning with explicit secret-state modes in `clone.go`, shared reflection and
cycle/limit state in `value_operation.go`, and configuration-value comparison
in `document_compare.go`; validation separately audits sensitivity and secrets;
- strict schema-driven decoding over detached defaults in `decode.go`, including
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
baseline/final trees, and initial default/input origins;
- public lifecycle coordination in `load.go`, option selection in `options.go`,
input assembly in `load_input.go`, typed phases/publication in `load_result.go`,
bounded canonical import DFS in `imports.go`, filesystem context/containment in
`filesystem.go`, directive parsing/restrictions in `directives.go`,
pattern-directed glob expansion in `glob.go`, schema-version checking and report
contexts in `validation.go`, and layered metadata/history assembly in
`load_metadata.go`;
- recorded application defaulting and normalization in `resolution.go`, including
explicitness queries, helper verification, structural mutation audit, coalesced
intent, private before/after snapshots, and resolution metadata integration;
- post-resolution schema-aware report collection and validator mutation detection in
`validation.go`, completing the public callback lifecycle after composed input;
- opaque typed environment references and per-load name resolution in
`secret_reference.go`, including credential-free projection/cloning and private
validator-state auditing;
- explicit bounded startup environment bootstrap and safe partial reporting in
`secrets.go`;
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
- canonical effective output in `render.go`, using frozen-snapshot redaction
shared with explanation in `snapshot.go`;
- detached public explanation records and coordination in `explain.go`, immutable
input applications/replay and retained-node budgeting in `history.go`, shared
safe snapshot projection in `snapshot.go`, and deterministic human output in
`explain_render.go`; the only upstream dumper integration remains isolated in
`internal/yaml/write.go`;
- a separate maintained `examples/consumers` package with four sanitized consumer
shapes, fixtures, domain validators, and runtime-boundary functions; and
- an everyday single-toolchain check in `scripts/check.sh` and a stricter
source-candidate checker in `scripts/release-check.sh`, with local
documentation-link and public import-boundary gates owned by root-package tests.
Dependency direction remains inward: the public lifecycle coordinates private - `internal/engine` owns source acquisition, composition, schema inspection,
source, document, schema, and inspection helpers. Semantic paths stay in the root decoding, lifecycle coordination, callback auditing, provenance/history,
package; `internal/document` owns only representation and cloning and never imports secret resolution and bootstrap, and safe inspection rendering. It never
the root package. Only `internal/yaml` imports the upstream YAML package. Maintained imports the root package.
consumer examples use the public package and standard library. - Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and
bootstrap reports are root-defined wrappers or records. Callback wrappers pass
only live engine context during a callback; `Result.Config` is detached and its
private inspection remains frozen independently.
- Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods.
- `internal/document` owns the parser-independent ordered tree and cloning.
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
- Engine mechanism tests own source, composition, decoding, callback, provenance,
secret, history, rendering, and bootstrap behavior. Root tests own public API
compatibility, facade integration, documentation links, and dependency/public
boundary checks. Consumer examples remain under `examples/consumers`.
The [configuration contract](../config.md) owns implemented input, schema, decoding, Dependency direction is root to engine to document/YAML support. The root may
loading lifecycle, path imports, composition, profile/CLI overlay, version checking, import only `internal/engine` among project internal production packages; engine
recorded resolution, typed secret references/bootstrap, semantic validation, and its supporting packages do not import root. The root boundary test enforces
metadata, secret classification, redacted effective rendering, historical these constraints and rejects exported aliases, fields, or signatures involving
explanation, and ownership behavior. internal types.
The [consumer adoption guide](../consumers/README.md) owns current application-fit
and migration guidance.
The source-extraction inventory records the application foundations used by the The [configuration contract](../config.md) owns implemented YAML and lifecycle
implementation: semantics. The [consumer adoption guide](../consumers/README.md) owns application
guidance. The [architecture policy](../policy/architecture.md) owns contributor
- [Extraction inventory](extraction.md) boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
decision. See the [development guide](../development.md) for validation commands
The [architecture policy](../policy/architecture.md) owns binding boundaries and and [release procedure](../release.md) for source-candidate gates.
invariants. The [deferred scope](../roadmap/deferred.md) records deliberate
exclusions. See the
[development guide](../development.md) for contributor orientation and validation
and the [release procedure](../release.md) for source-candidate gates.

View File

@@ -76,6 +76,18 @@ Do not expose parser-specific nodes, errors, or extension hooks through the
public API. Parser integration belongs behind a private boundary, as recorded public API. Parser integration belongs behind a private boundary, as recorded
in [ADR 0002](../adr/0002-own-yaml-semantics.md). in [ADR 0002](../adr/0002-own-yaml-semantics.md).
The root `configkit` package is the sole supported consumer import path. Public
named types stay root-owned and may hold private delegated implementation state;
their exported signatures, fields, and method sets must not expose internal
types or acquire internal methods through anonymous embedding. Root production
code may depend inward on a cohesive engine and its supporting internal packages,
but internal production packages must not import the root. Explicit forwarding
and local conversion are acceptable where they preserve type identity,
ownership, callback lifetime, and inspection behavior. The accepted root-wrapper
boundary and its migration rationale are recorded in
[ADR 0006](../adr/0006-public-facade.md); the implemented layout is described in
the [component overview](../internal/overview.md).
Separate input access, document processing, composition, decoding, metadata, and Separate input access, document processing, composition, decoding, metadata, and
rendering responsibilities without prescribing an interface or package for each. rendering responsibilities without prescribing an interface or package for each.
Dependencies must preserve those boundaries; package splits and abstractions Dependencies must preserve those boundaries; package splits and abstractions

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 0.1.0 **Base Policy Version:** 0.1.0
**Last Updated:** 2026-09-07 **Last Updated:** 2026-09-12
## Purpose ## Purpose
@@ -35,6 +35,20 @@ can still break consumers. Human diagnostic wording is stable only where a
contract expressly promises it. Minimum Go version and platform support changes contract expressly promises it. Minimum Go version and platform support changes
also require an explicit compatibility assessment. also require an explicit compatibility assessment.
### Platform support
Linux is the release-validated platform. macOS has best-effort support: keeping
it working remains desirable, but neither native macOS validation nor Darwin
cross-builds are release gates. Missing macOS evidence or a macOS-specific failure
does not by itself block a release. Voluntary checks and fixes are welcome; there
is no guarantee that each release has been validated on macOS.
The [release procedure](../release.md#release-identity-and-support) owns target
architectures and required Linux checks. This support change applies to upcoming
releases; historical release notes and their validation records remain unchanged.
The v0.3.0 release note must explain the change in Compatibility, with version
selection following this policy's existing compatibility rules.
## Release Model ## Release Model
A release consists of: A release consists of:

View File

@@ -14,11 +14,14 @@ binary, container image, generated package, or separate registry publication.
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported - Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
- Minimum Go language/toolchain line: Go 1.25, tested with a supported patched - Minimum Go language/toolchain line: Go 1.25, tested with a supported patched
Go 1.25 toolchain and one newer supported toolchain Go 1.25 toolchain and one newer supported toolchain
- Build targets: Linux and macOS on amd64 and arm64 - Release-gated build targets: Linux on amd64 and arm64
- Best-effort targets: macOS on amd64 and arm64, under the
[platform support policy](policy/release.md#platform-support)
The automated checker cross-builds all four targets. Before release, the maintainer The automated checker cross-builds the Linux targets. Before release, the maintainer
must also obtain native test, race, symlink, and permission-fixture evidence on Linux must also obtain native test, race, symlink, and permission-fixture evidence on Linux;
and macOS; cross-compilation is not native execution evidence. cross-compilation is not native execution evidence. Native macOS checks and Darwin
cross-builds are optional and do not gate publication.
## 1. Prepare access and tools ## 1. Prepare access and tools
@@ -97,10 +100,10 @@ release note until there is a real candidate and reviewed content.
## 4. Validate the source candidate ## 4. Validate the source candidate
Run the complete checker from the repository root after dependencies have been Run the complete checker on Linux from the repository root after dependencies have been
acquired. It validates module manifests in a disposable copy, both supported Go acquired. It validates module manifests in a disposable copy, both supported Go
toolchains, offline default tests, the native race detector, vet, native builds, toolchains, offline default tests, the native race detector, vet, native builds,
CGO-disabled cross-builds, formatting and whitespace (including untracked files), CGO-disabled Linux cross-builds, formatting and whitespace (including untracked files),
examples, package isolation, and local documentation links. examples, package isolation, and local documentation links.
```sh ```sh
@@ -120,9 +123,9 @@ Complete these manual gates and record their evidence with the candidate:
- The maintainer inspects `git status --short`, `git diff`, and the candidate file - The maintainer inspects `git status --short`, `git diff`, and the candidate file
list for credentials, private data, generated output, unintended files, and list for credentials, private data, generated output, unintended files, and
unrelated changes. unrelated changes.
- The maintainer records successful native checker runs on Linux and macOS, including - The maintainer records a successful native checker run on Linux, including
filesystem capability tests. If either host is unavailable, release remains filesystem capability tests. Missing required Linux evidence blocks release;
blocked unless platform support is formally narrowed in current documentation. no macOS run, sign-off, or cross-build result is required.
- The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs - The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs
inbound links, and reruns the checker. ADR rationale remains historical. inbound links, and reruns the checker. ADR rationale remains historical.
- The maintainer reviews the exact version-matched release note for accuracy and - The maintainer reviews the exact version-matched release note for accuracy and

70
docs/releases/v0.3.0.md Normal file
View File

@@ -0,0 +1,70 @@
# ConfigKit v0.3.0
This release adds source provenance during application resolution, separates the
public API from its internal engine, and moves macOS to best-effort support.
## Summary
Resolvers can query fixed entry-state metadata and already-acquired sources using
`Resolution.InputInfo` and `Resolution.Source`. This supports application-owned
decisions such as resolving relative paths against their declaring configuration
files. See the [resolution contract](../config.md#recorded-application-resolution)
and the maintained [source-aware path example](../consumers/README.md#source-aware-application-path-policy).
The root `configkit` package now owns public types and explicit wrappers around a
single private engine. Internal changes no longer automatically expand public
method sets through shared type aliases. See the [component overview](../internal/overview.md)
and [facade decision](../adr/0006-public-facade.md).
## Compatibility
Existing consumer imports, exported API signatures, configuration syntax,
precedence, secret handling, callback lifetime, and ownership behavior are
preserved. The two resolution query methods are additive and available only
during an active resolution callback; returned records remain detached. The
library supplies provenance, not a new filesystem normalization policy.
macOS on amd64 and arm64 is now best-effort rather than release-validated.
Neither native macOS evidence nor Darwin cross-builds are required for publication,
and macOS-specific failures do not by themselves block a release. Linux on amd64
and arm64 retains release-gated builds and native Linux validation. See the
[platform support policy](../policy/release.md#platform-support).
The module still requires Go 1.25 or newer. The private YAML dependency remains
pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4
release during preparation. It is still a prerelease dependency, isolated behind
the existing private parser boundary; no dependency upgrade is included.
Linux candidate validation uses Go 1.25.14 and Go 1.26.8, including the complete
release checker, race tests, consumer examples, filesystem fixtures, and Linux
cross-builds. No native macOS validation is claimed for this candidate.
## Upgrade
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.0
```
No code or configuration migration is required for the facade refactor. Adopt the
new provenance queries only if your resolver needs them. Applications retain
ownership of relative-path bases and fallback/error behavior.
macOS consumers should account for the reduced validation commitment and perform
their own application checks when upgrading if macOS behavior is important to
their deployment.
## Changes
- Added callback-scoped, fixed entry metadata and source lookup without additional
filesystem access or credential resolution.
- Added a maintained Notarius-shaped path-normalization example with provenance,
frozen effective output, and explanation coverage.
- Moved loading, schema, resolution, provenance/history, secret, and rendering
machinery into one internal engine while keeping public named types root-owned.
- Added exact-type secret and callback adapters, with regression coverage for
missing version checkers and nil/zero closed-context failures.
- Added type-aware public-boundary checks covering aliases, collections, generic
constraints, and exported method sets; restored public reference documentation.
- Removed macOS release gates and retained required Linux validation.
- Retired completed feature, implementation, and remediation documents to Git
history and repaired navigation to current contract and component owners.

43
docs/releases/v0.3.1.md Normal file
View File

@@ -0,0 +1,43 @@
# ConfigKit v0.3.1
This release fixes secrets-directory bootstrap when the directory contains Git
metadata or other dot-prefixed entries.
## Summary
`LoadSecretsDir` now ignores dot-prefixed files, directories, and symlinks,
including `.git`, `.gitignore`, and `.DS_Store`. These entries no longer prevent
otherwise valid secrets from loading and are omitted from the bootstrap report.
See the [bootstrap contract](../config.md#explicit-secrets-directory-bootstrap).
## Compatibility
This is a backward-compatible bug fix for previously rejected directory contents.
There are no Go API signature changes or migrations for previously accepted
inputs. Validation of non-hidden entries, existing-environment precedence, and
the existing enumeration and content limits remain unchanged. Hidden entries
still count toward the directory enumeration limit.
The minimum Go version remains 1.25. Linux remains release-validated and macOS
remains best-effort. The explicitly pinned YAML dependency remains
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; reassessment found no newer published version.
Retaining this prerelease pin avoids unrelated parser changes in this patch.
## Upgrade
Update the module dependency and rebuild the consuming application:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.1
```
No configuration migration is required. A secrets directory can retain its
dot-prefixed metadata without treating those entries as secret candidates.
## Changes
- Filter hidden entries before filename and metadata validation and reporting.
- Cover hidden-only and mixed directories, including Git metadata and a dangling
hidden symlink, with regression tests.
- Update public API comments and the canonical bootstrap contract.
- Stop tracking local codebase-memory tooling artifacts and ignore their directory.

View File

@@ -4,6 +4,7 @@ package consumers
import ( import (
"math" "math"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -146,6 +147,69 @@ func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
return int(value), true return int(value), true
} }
// NotariusPathConfig demonstrates application-owned interpretation of a
// path-valued configuration field. ConfigKit supplies provenance; this example
// selects its own relative-path bases.
type NotariusPathConfig struct {
Reference string `yaml:"reference"`
Sensitive string `yaml:"sensitive"`
}
// LoadNotariusPaths loads the path-bearing Notarius-shaped example. InvocationDir
// is the application-supplied base for relative CLI overrides; it is never read
// from the process working directory.
func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Override) (*configkit.Result[NotariusPathConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NotariusPathConfig]{
Imports: true, Overrides: overrides,
Definition: configkit.Definition[NotariusPathConfig]{
Defaults: func() NotariusPathConfig { return NotariusPathConfig{Reference: "defaults/reference.txt"} },
Resolve: func(configuration *NotariusPathConfig, resolution *configkit.Resolution) error {
resolveNotariusReference(configuration, resolution, invocationDir)
return nil
},
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" },
},
})
}
func resolveNotariusReference(configuration *NotariusPathConfig, resolution *configkit.Resolution, invocationDir string) {
path := configkit.Keys("reference")
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
return
}
info, found := resolution.InputInfo(path)
if !found {
resolution.Warning(path, "relative reference has no entry provenance")
return
}
base := ""
if info.Origin.Layer == configkit.LayerCLI {
base = invocationDir
if base == "" {
resolution.Warning(path, "relative CLI reference has no invocation directory")
return
}
} else {
sourceID := "source-1" // ConfigKit's primary-source identity convention.
if info.Origin.Layer != configkit.LayerDefault {
if len(info.Origin.Locations) != 1 {
resolution.Warning(path, "relative reference has no unambiguous declaring source")
return
}
sourceID = info.Origin.Locations[0].SourceID
}
source, exists := resolution.Source(sourceID)
if !exists || source.Path == "" {
resolution.Warning(path, "relative reference has no declaring file")
return
}
base = filepath.Dir(source.Path)
}
configkit.Normalize(resolution, path, &configuration.Reference,
filepath.Join(base, configuration.Reference), "resolved relative reference from entry source")
}
// ModuleOptions is a WeatherReporter-shaped union of known module option fields. // ModuleOptions is a WeatherReporter-shaped union of known module option fields.
type ModuleOptions struct { type ModuleOptions struct {
WindowUnits *int `yaml:"window_units"` WindowUnits *int `yaml:"window_units"`

View File

@@ -29,6 +29,12 @@ func Example_notarius() {
// Output: 4 summary 12 true <nil> // Output: 4 summary 12 true <nil>
} }
func Example_notariusPaths() {
result, err := consumers.LoadNotariusPaths(fixture("notarius-paths", "main.yml"), "/invocation")
fmt.Println(filepath.Base(result.Config.Reference), err)
// Output: imported.txt <nil>
}
func Example_weatherReporter() { func Example_weatherReporter() {
defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource()) defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource())
result, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml"))) result, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
@@ -240,12 +246,89 @@ func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
} }
} }
func TestNotariusPathExampleUsesEntryProvenance(t *testing.T) {
main := fixture("notarius-paths", "main.yml")
declared := fixture("notarius-paths", "declarations", "path.yml")
declaredCanonical := canonicalFixture(t, declared)
wantImported := filepath.Join(filepath.Dir(declaredCanonical), "..", "assets", "imported.txt")
result, err := consumers.LoadNotariusPaths(main, filepath.Join(t.TempDir(), "invocation"))
if err != nil || result.Config.Reference != wantImported {
t.Fatalf("imported reference = %#v, %v", result, err)
}
var effective bytes.Buffer
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), wantImported) ||
strings.Contains(effective.String(), "PATH_REFERENCE_SECRET_CANARY") {
t.Fatalf("effective output = %q, %v", effective.String(), err)
}
explanation, found := result.Explain(configkit.Keys("reference"))
if !found || len(explanation.History) == 0 || explanation.History[len(explanation.History)-1].Before.YAML != "../assets/imported.txt\n" ||
len(explanation.History[len(explanation.History)-1].Reasons) != 1 || explanation.History[len(explanation.History)-1].Reasons[0] != "resolved relative reference from entry source" {
t.Fatalf("reference explanation = %#v, %v", explanation, found)
}
sensitive, found := result.Explain(configkit.Keys("sensitive"))
var rendered bytes.Buffer
writeErr := result.WriteExplanation(&rendered, configkit.Keys("sensitive"))
if !found || !sensitive.Effective.Redacted || writeErr != nil ||
strings.Contains(fmt.Sprintf("%#v %s", sensitive, rendered.String()), "PATH_REFERENCE_SECRET_CANARY") {
t.Fatalf("sensitive inspection = %#v, %q, %v", sensitive, rendered.String(), writeErr)
}
defaults := fixture("notarius-paths", "defaults.yml")
defaultCanonical := canonicalFixture(t, defaults)
defaultResult, err := consumers.LoadNotariusPaths(defaults, filepath.Join(t.TempDir(), "invocation"))
wantDefault := filepath.Join(filepath.Dir(defaultCanonical), "defaults", "reference.txt")
if err != nil || defaultResult.Config.Reference != wantDefault {
t.Fatalf("default reference = %#v, %v", defaultResult, err)
}
invocation := filepath.Join(t.TempDir(), "caller")
cliResult, err := consumers.LoadNotariusPaths(main, invocation,
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
wantCLI := filepath.Join(invocation, "cli", "reference.txt")
if err != nil || cliResult.Config.Reference != wantCLI {
t.Fatalf("CLI reference = %#v, %v", cliResult, err)
}
missingBase, err := consumers.LoadNotariusPaths(main, "",
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
if err != nil || missingBase.Config.Reference != "cli/reference.txt" || len(missingBase.Diagnostics()) != 1 ||
missingBase.Diagnostics()[0].Code != configkit.CodeResolveWarning {
t.Fatalf("missing CLI base = %#v, %v", missingBase, err)
}
absolute := filepath.Join(t.TempDir(), "absolute.txt")
absoluteResult, err := consumers.LoadNotariusPaths(main, invocation,
configkit.Override{Path: configkit.Keys("reference"), Value: absolute, Flag: "--reference"})
if err != nil || absoluteResult.Config.Reference != absolute {
t.Fatalf("absolute reference = %#v, %v", absoluteResult, err)
}
target := fixture("notarius-paths", "symlink-target.yml")
link := filepath.Join(t.TempDir(), "linked-config.yml")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink fixture unavailable: %v", err)
}
symlinkResult, err := consumers.LoadNotariusPaths(link, invocation)
targetCanonical := canonicalFixture(t, target)
wantSymlink := filepath.Join(filepath.Dir(targetCanonical), "..", "assets", "target.txt")
if err != nil || symlinkResult.Config.Reference != wantSymlink {
t.Fatalf("symlink reference = %#v, %v", symlinkResult, err)
}
}
func fixture(parts ...string) string { func fixture(parts ...string) string {
_, file, _, _ := runtime.Caller(0) _, file, _, _ := runtime.Caller(0)
values := append([]string{filepath.Dir(file), "testdata"}, parts...) values := append([]string{filepath.Dir(file), "testdata"}, parts...)
return filepath.Join(values...) return filepath.Join(values...)
} }
func canonicalFixture(t *testing.T, path string) string {
t.Helper()
canonical, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
return canonical
}
func preserveEnvironment(name string) func() { func preserveEnvironment(name string) func() {
value, present := os.LookupEnv(name) value, present := os.LookupEnv(name)
return func() { return func() {

View File

@@ -0,0 +1 @@
reference: ../assets/imported.txt

View File

@@ -0,0 +1 @@
sensitive: PATH_REFERENCE_SECRET_CANARY

View File

@@ -0,0 +1,4 @@
configkit:
imports:
- path: declarations/path.yml
sensitive: PATH_REFERENCE_SECRET_CANARY

View File

@@ -0,0 +1,2 @@
reference: ../assets/target.txt
sensitive: PATH_REFERENCE_SECRET_CANARY

272
facade_candidate_test.go Normal file
View File

@@ -0,0 +1,272 @@
package configkit_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
var (
_ error = (*configkit.LoadError)(nil)
_ fmt.Formatter = configkit.Secret{}
_ fmt.Stringer = configkit.Path{}
_ json.Marshaler = configkit.Secret{}
)
func TestFacadeValuesRemainRootOwned(t *testing.T) {
t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
for _, candidate := range []reflect.Type{
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
reflect.TypeFor[configkit.LoadError](), reflect.TypeFor[configkit.Metadata](),
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
reflect.TypeFor[configkit.Result[struct{}]](),
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),
reflect.TypeFor[configkit.SecretReferenceInfo](), reflect.TypeFor[configkit.Explanation](),
reflect.TypeFor[configkit.SecretFileResult](), reflect.TypeFor[configkit.SecretsReport](),
reflect.TypeFor[configkit.SecretsError](),
} {
if candidate.PkgPath() != packagePath {
t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath())
}
}
secret := configkit.SecretEnv("SAFE_NAME")
formatted := fmt.Sprintf("%#v", secret)
encoded, err := json.Marshal(secret)
if formatted == "" || err != nil || string(encoded) != `"SAFE_NAME"` {
t.Fatalf("secret rendering = %q, %s, %v", formatted, encoded, err)
}
if got := configkit.Keys("a/b~").Index(0).String(); got != "/a~1b~0/0" {
t.Fatalf("path = %q", got)
}
if got := (&configkit.LoadError{}).Error(); got != "configkit: configuration load failed" {
t.Fatalf("zero LoadError = %q", got)
}
}
func TestFacadeInspectionUsesFrozenState(t *testing.T) {
t.Setenv("FACADE_INSPECTION_SECRET", "credential")
type configuration struct {
Value string `yaml:"value"`
Secret configkit.Secret `yaml:"secret"`
}
result, err := configkit.Load[configuration](configkit.Bytes("inspection.yml", []byte("value: original\nsecret: FACADE_INSPECTION_SECRET\n")))
if err != nil {
t.Fatal(err)
}
if !result.Config.Secret.IsResolved() || result.Config.Secret.Value() != "credential" {
t.Fatalf("secret = %#v", result.Config.Secret)
}
explanation, ok := result.Explain(configkit.Keys("value"))
if !ok || explanation.Effective.YAML != "original\n" {
t.Fatalf("explanation = %#v, %v", explanation, ok)
}
explanation.Effective.YAML = "mutated\n"
result.Config.Value = "mutated"
var effective bytes.Buffer
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "value: original\n") || strings.Contains(effective.String(), "credential") {
t.Fatalf("effective = %q, %v", effective.String(), err)
}
again, ok := result.Explain(configkit.Keys("value"))
if !ok || again.Effective.YAML != "original\n" {
t.Fatalf("inspection changed = %#v, %v", again, ok)
}
secret, ok := result.Explain(configkit.Keys("secret"))
if !ok || secret.SecretReference == nil || secret.SecretReference.Name != "FACADE_INSPECTION_SECRET" {
t.Fatalf("secret explanation = %#v, %v", secret, ok)
}
var explanationOutput bytes.Buffer
if err := result.WriteExplanation(&explanationOutput, configkit.Keys("value")); err != nil || !strings.Contains(explanationOutput.String(), `path: "/value"`) {
t.Fatalf("explanation output = %q, %v", explanationOutput.String(), err)
}
unknown := bytes.NewBufferString("unchanged")
if err := result.WriteExplanation(unknown, configkit.Keys("unknown")); err == nil || unknown.String() != "unchanged" {
t.Fatalf("unknown explanation = %q, %v", unknown.String(), err)
}
var nilResult *configkit.Result[configuration]
if _, ok := nilResult.Explain(configkit.Keys("value")); ok {
t.Fatal("nil result explained a path")
}
if err := nilResult.WriteEffective(io.Discard); err == nil {
t.Fatal("nil result rendered effective configuration")
}
}
func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
const (
loadedName = "FACADE_BOOTSTRAP_LOADED"
existingName = "FACADE_BOOTSTRAP_EXISTING"
)
restore := func(name string) {
value, present := os.LookupEnv(name)
t.Cleanup(func() {
if present {
_ = os.Setenv(name, value)
} else {
_ = os.Unsetenv(name)
}
})
}
restore(loadedName)
restore(existingName)
if err := os.Unsetenv(loadedName); err != nil {
t.Fatal(err)
}
if err := os.Setenv(existingName, ""); err != nil {
t.Fatal(err)
}
directory := t.TempDir()
if err := os.WriteFile(directory+"/"+loadedName, []byte("bootstrap\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(directory+"/"+existingName, []byte("ignored\n"), 0o600); err != nil {
t.Fatal(err)
}
report, err := configkit.LoadSecretsDir(directory)
if err != nil {
t.Fatal(err)
}
entries := report.Entries()
if len(entries) != 2 || os.Getenv(loadedName) != "bootstrap" {
t.Fatalf("entries/environment = %#v / %q", entries, os.Getenv(loadedName))
}
status := map[string]configkit.SecretFileResult{}
for _, entry := range entries {
status[entry.Name] = entry
}
if status[loadedName].Status != configkit.SecretFileLoaded || status[existingName].Status != configkit.SecretFileSkippedExisting || !status[existingName].ExistingEmpty {
t.Fatalf("statuses = %#v", status)
}
entries[0].Name = "mutated"
if report.Entries()[0].Name == "mutated" {
t.Fatal("report entries share mutable state")
}
var rendered bytes.Buffer
if _, err := report.WriteTo(&rendered); err != nil || strings.Contains(rendered.String(), "bootstrap") || strings.Contains(rendered.String(), "ignored") {
t.Fatalf("report rendering = %q, %v", rendered.String(), err)
}
failedReport, err := configkit.LoadSecretsDir("")
var secretsError *configkit.SecretsError
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
}
}
func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
t.Setenv("FACADE_SECRET_NAME", "credential")
type configuration struct {
Version string `yaml:"version"`
Input int `yaml:"input"`
Derived int `yaml:"derived"`
Secret configkit.Secret `yaml:"secret"`
Labels map[string]string `yaml:"labels"`
}
var retainedResolution *configkit.Resolution
var retainedValidation *configkit.Validation
versionCalls, defaultsCalls, resolveCalls, validateCalls := 0, 0, 0, 0
definition := configkit.Definition[configuration]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
versionCalls++
retainedValidation = validation
if !version.Present || version.Text != "1" {
validation.Error(configkit.Keys("version"), "unsupported version")
}
return nil
}},
Defaults: func() configuration {
defaultsCalls++
return configuration{Derived: 1, Labels: map[string]string{"default": "kept"}}
},
Resolve: func(configuration *configuration, resolution *configkit.Resolution) error {
resolveCalls++
retainedResolution = resolution
if !resolution.IsExplicit(configkit.Keys("input")) {
resolution.Error(configkit.Keys("input"), "input is required")
}
input, ok := resolution.InputInfo(configkit.Keys("input"))
if !ok || !input.Explicit {
resolution.Error(configkit.Keys("input"), "missing input provenance")
}
source, ok := resolution.Source("source-1")
if !ok || source.Label != "facade.yml" {
resolution.Error(configkit.Keys("input"), "missing source")
}
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", configkit.Keys("input"))
return nil
},
Validate: func(configuration *configuration, validation *configkit.Validation) {
validateCalls++
retainedValidation = validation
if !configuration.Secret.IsResolved() || configuration.Secret.Value() != "credential" {
validation.Error(configkit.Keys("secret"), "secret did not resolve")
}
},
}
if versionCalls != 0 || defaultsCalls != 0 || resolveCalls != 0 || validateCalls != 0 {
t.Fatal("callbacks ran while constructing options")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("version: 1\ninput: 3\nsecret: FACADE_SECRET_NAME\n")), configkit.Options[configuration]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if versionCalls != 1 || defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || result.Config.Derived != 6 {
t.Fatalf("callback counts/configuration = %d/%d/%d/%d %#v", versionCalls, defaultsCalls, resolveCalls, validateCalls, result.Config)
}
derived, ok := result.Metadata().Lookup(configkit.Keys("derived"))
if !ok || derived.Explicit || derived.Origin.Operation != configkit.ChangeDerivedDefault {
t.Fatalf("derived metadata = %#v, %v", derived, ok)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].Label != "facade.yml" {
t.Fatalf("sources = %#v", sources)
}
if result.Diagnostics() != nil {
t.Fatalf("diagnostics = %#v", result.Diagnostics())
}
for _, context := range []func(){
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
} {
func() {
defer func() {
if recover() == nil {
t.Fatal("retained callback context remained active")
}
}()
context()
}()
}
}
func TestFacadeLoadReturnsFacadeLoadError(t *testing.T) {
t.Parallel()
type configuration struct {
Value int `yaml:"value"`
}
result, err := configkit.Load[configuration](configkit.Bytes("invalid.yml", []byte("value: wrong\n")))
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeDecodeType {
t.Fatalf("facade error = %#v", err)
}
}

View File

@@ -0,0 +1,85 @@
package configkit_test
import (
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
var (
_ error = (*configkit.LoadError)(nil)
_ fmt.Formatter = configkit.Secret{}
_ fmt.Stringer = configkit.Path{}
_ json.Marshaler = configkit.Secret{}
_ io.WriterTo = (*configkit.SecretsReport)(nil)
)
type facadeCompatibilityConfig struct {
Value string `yaml:"value"`
Derived string `yaml:"derived"`
}
func TestFacadeCompatibility(t *testing.T) {
t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
for _, candidate := range []struct {
name string
type_ reflect.Type
}{
{"Path", reflect.TypeFor[configkit.Path]()},
{"Source", reflect.TypeFor[configkit.Source]()},
{"Secret", reflect.TypeFor[configkit.Secret]()},
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
} {
if candidate.type_.PkgPath() != packagePath {
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
}
}
constructed := configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{},
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
Overrides: []configkit.Override{{
Path: configkit.Keys("value"),
Value: "override",
Flag: "--value",
}},
}
if got := constructed.Overrides[0].Path.String(); got != "/value" {
t.Fatalf("constructed override path = %q, want /value", got)
}
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
if publicResult.Config.Value != "constructed" {
t.Fatal("constructed result lost its public configuration")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Value+" default", "derive value")
configkit.Normalize(resolution, configkit.Keys("value"), &configuration.Value, "normalized "+configuration.Value, "normalize value")
return nil
},
},
})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Config != (facadeCompatibilityConfig{Value: "normalized input", Derived: "input default"}) {
t.Fatalf("Load() Config = %#v", result.Config)
}
_, err = configkit.Load[facadeCompatibilityConfig](configkit.Bytes("invalid.yml", []byte("value: [\n")))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("Load() error = %T, want *configkit.LoadError", err)
}
}

51
facade_context_test.go Normal file
View File

@@ -0,0 +1,51 @@
package configkit_test
import (
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
t.Parallel()
for _, state := range []struct {
name string
resolution *configkit.Resolution
validation *configkit.Validation
}{
{name: "nil"},
{name: "zero", resolution: &configkit.Resolution{}, validation: &configkit.Validation{}},
} {
t.Run(state.name, func(t *testing.T) {
path := configkit.Keys("value")
var target int
for _, operation := range []struct {
name string
call func()
}{
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }},
{"Source", func() { state.resolution.Source("source") }},
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},
{"Normalize", func() { configkit.Normalize(state.resolution, path, &target, 1, "reason") }},
{"Validation.Error", func() { state.validation.Error(path, "message") }},
{"Validation.Warning", func() { state.validation.Warning(path, "message") }},
} {
t.Run(operation.name, func(t *testing.T) {
defer func() {
if got := recover(); got != "configkit: callback context is no longer active" {
t.Errorf("panic = %v; want fixed closed-context failure", got)
}
if target != 0 {
t.Error("inactive helper changed target")
}
}()
operation.call()
})
}
})
}
}

52
facade_internal_test.go Normal file
View File

@@ -0,0 +1,52 @@
package configkit
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
data := []byte("value: retained\n")
source := Bytes("snapshot.yml", data)
data[7] = 'X'
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
if err != nil || result.Config.Value != "retained" {
t.Fatalf("snapshotted source = %#v, %v", result, err)
}
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
first := readerSource
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
t.Fatal(err)
}
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
var loadError *engine.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
t.Fatalf("copied reader source error = %#v", err)
}
}
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
t.Parallel()
parsed, err := ParsePath("/0")
if err != nil {
t.Fatal(err)
}
keyed, indexed := Keys("0"), Path{}.Index(0)
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
}
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
}
}

66
facade_version_test.go Normal file
View File

@@ -0,0 +1,66 @@
package configkit_test
import (
"errors"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type versionCountingReader struct {
*strings.Reader
reads int
}
func (r *versionCountingReader) Read(data []byte) (int, error) {
r.reads++
return r.Reader.Read(data)
}
func TestFacadeRejectsMissingVersionCheckerBeforeReading(t *testing.T) {
t.Parallel()
type configuration struct {
Version string `yaml:"version"`
}
reader := &versionCountingReader{Reader: strings.NewReader("version: v1\n")}
source := configkit.Reader("version.yml", reader)
var defaults, resolutions, validations, checks int
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Version: &configkit.VersionRule{Path: configkit.Keys("version")},
Defaults: func() configuration { defaults++; return configuration{} },
Resolve: func(*configuration, *configkit.Resolution) error { resolutions++; return nil },
Validate: func(*configuration, *configkit.Validation) { validations++ },
}}
result, err := configkit.Load(source, options)
var loadError *configkit.LoadError
if result != nil || !errors.As(err, &loadError) {
t.Fatalf("missing checker: result = %v, error = %v", result, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeOptionsInvalid {
t.Fatalf("diagnostics = %v", diagnostics)
}
if reader.reads != 0 || defaults != 0 || resolutions != 0 || validations != 0 {
t.Fatalf("invalid options performed work: reads=%d defaults=%d resolve=%d validate=%d", reader.reads, defaults, resolutions, validations)
}
options.Definition.Version.Check = func(version configkit.Version, validation *configkit.Validation) error {
checks++
if version != (configkit.Version{Present: true, Text: "v1"}) {
t.Errorf("version = %#v", version)
}
validation.Warning(configkit.Keys("version"), "version accepted")
return nil
}
result, err = configkit.Load(source, options)
if err != nil || result == nil || result.Config.Version != "v1" {
t.Fatalf("reuse with checker: result = %v, error = %v", result, err)
}
if checks != 1 || defaults != 1 || resolutions != 1 || validations != 1 {
t.Fatalf("callback counts: check=%d defaults=%d resolve=%d validate=%d", checks, defaults, resolutions, validations)
}
diagnostics = result.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionWarning {
t.Fatalf("version callback diagnostics = %v", diagnostics)
}
}

View File

@@ -41,6 +41,15 @@ func TestDependencyImportBoundary(t *testing.T) {
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") { if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
t.Errorf("%s imports test-only dependency %s", path, importPath) t.Errorf("%s imports test-only dependency %s", path, importPath)
} }
production := !strings.HasSuffix(path, "_test.go")
rootProduction := production && filepath.Dir(path) == "."
internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/")
if rootProduction && strings.HasPrefix(importPath, "gitea.maximumdirect.net/eric/ConfigKit/internal/") && importPath != "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" {
t.Errorf("%s bypasses the engine boundary through %s", path, importPath)
}
if internalProduction && importPath == "gitea.maximumdirect.net/eric/ConfigKit" {
t.Errorf("%s imports the public root package", path)
}
} }
return nil return nil
}) })

70
inspection.go Normal file
View File

@@ -0,0 +1,70 @@
package configkit
import (
"io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// ValueSnapshot is a detached, deterministic YAML representation of one value.
// Present distinguishes absence or deletion from an explicit null value.
type ValueSnapshot struct {
Present bool
Redacted bool
YAML string
}
// ValueChange describes one explicit definition or audited domain operation.
type ValueChange struct {
Before ValueSnapshot
After ValueSnapshot
Origin Origin
Reasons []string
Dependencies []Path
}
// SecretReferenceInfo describes a successfully resolved effective reference.
// Name is the environment-variable name, never its value.
type SecretReferenceInfo struct {
Name string
}
// Explanation is a detached safe account of one effective or historical path.
type Explanation struct {
Path Path
Effective ValueSnapshot
Origin Origin
History []ValueChange
SecretReference *SecretReferenceInfo
}
// Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil {
return Explanation{}, false
}
value, ok := r.inspection.Explain(toEnginePath(path))
return fromEngineExplanation(value), ok
}
// WriteEffective writes deterministic redacted YAML from the frozen resolved
// snapshot. The output is intended for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error {
if r == nil {
var inspection *engine.Inspection
return inspection.WriteEffective(output)
}
return r.inspection.WriteEffective(output)
}
// WriteExplanation writes a deterministic human-readable rendering of Explain.
// Unknown paths produce no output. Writer failures may leave partial output.
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
if r == nil {
var inspection *engine.Inspection
return inspection.WriteExplanation(output, toEnginePath(path))
}
return r.inspection.WriteExplanation(output, toEnginePath(path))
}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "testing" import "testing"
@@ -30,11 +30,11 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
} }
calls := 0 calls := 0
lookup := func(string) (string, bool) { calls++; return "credential", true } lookup := func(string) (string, bool) { calls++; return "credential", true }
first, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition}) first, err := testLoadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
second, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition}) second, err := testLoadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"encoding" "encoding"
@@ -128,11 +128,11 @@ func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor,
return result, nil return result, nil
case descriptorSecret: case descriptorSecret:
result := reflect.New(value.Type()).Elem() result := reflect.New(value.Type()).Elem()
reference := value.Interface().(Secret) reference := o.schema.secrets.Read(value)
if o.cloneMode == cloneValidatedSecrets { if o.cloneMode == cloneValidatedSecrets {
result.Set(value) result.Set(value)
} else if reference.specified { } else if reference.Specified {
result.Set(reflect.ValueOf(SecretEnv(reference.EnvName()))) result.Set(o.schema.secrets.Make(SecretState{Name: reference.Name, Specified: true}))
} }
return result, nil return result, nil
case descriptorText: case descriptorText:

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"reflect" "reflect"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"encoding" "encoding"
@@ -179,7 +179,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
if node.Kind != document.String { if node.Kind != document.String {
return typeMismatch(path, node, "secret environment name") return typeMismatch(path, node, "secret environment name")
} }
return reflect.ValueOf(SecretEnv(node.Text)), nil return schema.secrets.Make(SecretState{Name: node.Text, Specified: true}), nil
case descriptorText: case descriptorText:
if node.Kind != document.String { if node.Kind != document.String {
return typeMismatch(path, node, "text string") return typeMismatch(path, node, "text string")

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"

View File

@@ -0,0 +1,248 @@
package engine
import (
"cmp"
"fmt"
"io"
"slices"
"strconv"
"strings"
"unicode"
)
// LoadError reports safe structured diagnostics for a failed configuration load.
// It intentionally does not expose or unwrap parser and application errors.
type LoadError struct {
diagnostics []Diagnostic
sources []SourceInfo
}
// Error implements error without rendering diagnostic messages or input values.
func (e *LoadError) Error() string {
if e == nil {
return "configkit: configuration load failed"
}
count := 0
for _, diagnostic := range e.diagnostics {
if diagnostic.Severity == SeverityError {
count++
}
}
if count == 1 {
return "configkit: configuration load failed with 1 error"
}
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
}
// Diagnostics returns a deep copy of the error's canonical diagnostics.
func (e *LoadError) Diagnostics() []Diagnostic {
if e == nil {
return nil
}
return cloneDiagnostics(e.diagnostics)
}
// Sources returns a deep copy of sources discovered before loading failed.
func (e *LoadError) Sources() []SourceInfo {
if e == nil {
return nil
}
return cloneSourceInfos(e.sources)
}
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
canonical := canonicalDiagnostics(diagnostics)
for _, diagnostic := range canonical {
if err := writeDiagnostic(output, diagnostic); err != nil {
return err
}
}
return nil
}
type diagnosticAccumulator struct {
diagnostics []Diagnostic
sources []SourceInfo
}
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
for _, diagnostic := range diagnostics {
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
}
}
func (a *diagnosticAccumulator) loadError() *LoadError {
return &LoadError{
diagnostics: canonicalDiagnostics(a.diagnostics),
sources: cloneSourceInfos(a.sources),
}
}
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
canonical := cloneDiagnostics(diagnostics)
for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation)
}
slices.SortFunc(canonical, compareDiagnostic)
return canonical
}
func compareDiagnostic(left, right Diagnostic) int {
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
return order
}
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
return order
}
if order := cmp.Compare(left.Code, right.Code); order != 0 {
return order
}
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
return order
}
if order := cmp.Compare(left.Message, right.Message); order != 0 {
return order
}
return compareLocations(left.Related, right.Related)
}
func compareOptionalLocation(left, right *Location) int {
if left == nil && right == nil {
return 0
}
if left == nil {
return -1
}
if right == nil {
return 1
}
return compareLocation(*left, *right)
}
func compareLocations(left, right []Location) int {
for i := 0; i < min(len(left), len(right)); i++ {
if order := compareLocation(left[i], right[i]); order != 0 {
return order
}
}
return cmp.Compare(len(left), len(right))
}
func compareLocation(left, right Location) int {
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
return order
}
if order := cmp.Compare(left.Line, right.Line); order != 0 {
return order
}
return cmp.Compare(left.Column, right.Column)
}
func compareSourceID(left, right string) int {
leftOrder, leftOK := sourceSequence(left)
rightOrder, rightOK := sourceSequence(right)
if leftOK && rightOK {
return cmp.Compare(leftOrder, rightOrder)
}
return cmp.Compare(left, right)
}
func sourceSequence(sourceID string) (int, bool) {
const prefix = "source-"
if !strings.HasPrefix(sourceID, prefix) {
return 0, false
}
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
if err != nil || value < 1 {
return 0, false
}
return value, true
}
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
severity := diagnostic.Severity
if severity == "" {
severity = SeverityError
}
path := diagnostic.Path.String()
if path == "" {
path = "<root>"
}
var b strings.Builder
b.WriteString(escapeControls(string(severity)))
if diagnostic.Code != "" {
b.WriteString(" [")
b.WriteString(escapeControls(string(diagnostic.Code)))
b.WriteByte(']')
}
b.WriteByte(' ')
b.WriteString(escapeControls(path))
if diagnostic.Primary != nil {
b.WriteString(" (")
writeLocationText(&b, *diagnostic.Primary)
b.WriteByte(')')
}
if diagnostic.Message != "" {
b.WriteString(": ")
b.WriteString(escapeControls(diagnostic.Message))
}
b.WriteByte('\n')
for _, related := range diagnostic.Related {
b.WriteString(" related: ")
writeLocationText(&b, related)
b.WriteByte('\n')
}
return writeString(output, b.String())
}
func writeLocationText(output *strings.Builder, location Location) {
label := location.SourceID
if label == "" {
label = "<synthetic>"
}
output.WriteString(escapeControls(label))
if location.Line > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Line))
if location.Column > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Column))
}
}
}
func writeString(output io.Writer, value string) error {
written, err := io.WriteString(output, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func escapeControls(value string) string {
var b strings.Builder
for _, r := range value {
if unicode.IsControl(r) {
quoted := strconv.QuoteRune(r)
b.WriteString(quoted[1 : len(quoted)-1])
continue
}
b.WriteRune(r)
}
return b.String()
}
func hasErrors(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == SeverityError {
return true
}
}
return false
}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"path" "path"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"cmp" "cmp"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
@@ -36,7 +36,7 @@ type Explanation struct {
// Explain returns safe effective and chronological values for a known final or // Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false. // historical path. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) { func (r *Inspection) Explain(path Path) (Explanation, bool) {
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) { if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
return Explanation{}, false return Explanation{}, false
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -14,7 +14,7 @@ var (
// WriteExplanation writes a deterministic human-readable rendering of Explain. // WriteExplanation writes a deterministic human-readable rendering of Explain.
// Unknown paths produce no output. Writer failures may leave partial output. // Unknown paths produce no output. Writer failures may leave partial output.
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error { func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
if r == nil || output == nil { if r == nil || output == nil {
return errExplanationUnavailable return errExplanationUnavailable
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"
@@ -26,7 +26,7 @@ func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n") mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n") writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n") writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{ result, err := testLoad[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
Imports: true, Profile: "testing", Imports: true, Profile: "testing",
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency { Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
value := explanationConcurrency{} value := explanationConcurrency{}
@@ -84,7 +84,7 @@ items: [profile]
labels: {profile: retained} labels: {profile: retained}
dynamic: selected dynamic: selected
`) `)
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) { result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "APP_SECRET" { if name != "APP_SECRET" {
t.Fatalf("secret lookup = %q", name) t.Fatalf("secret lookup = %q", name)
} }
@@ -285,7 +285,7 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
type config struct { type config struct {
Credential Secret `yaml:"credential"` Credential Secret `yaml:"credential"`
} }
result, err := Load[config](Bytes("config.yml", []byte("credential: "+name+"\n"))) result, err := testLoad[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -343,7 +343,7 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n") writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
opts.Profile = "p" opts.Profile = "p"
} }
result, err := Load(File(main), opts) result, err := testLoad(File(main), opts)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -380,11 +380,11 @@ func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.
Defaults: func() config { return config{Version: "4"} }, Defaults: func() config { return config{Version: "4"} },
Version: rule, Version: rule,
}} }}
first, err := Load(Bytes("version.yml", []byte("version: 4\n")), options) first, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
second, err := Load(Bytes("version.yml", []byte("version: 4\n")), options) second, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -449,7 +449,7 @@ func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) {
Version string `yaml:"version"` Version string `yaml:"version"`
} }
lookups := 0 lookups := 0
result, err := loadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
lookups++ lookups++
return "CREDENTIAL_CANARY", true return "CREDENTIAL_CANARY", true
}, Options[config]{Definition: Definition[config]{ }, Options[config]{Definition: Definition[config]{
@@ -476,7 +476,7 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
} }
for _, overlap := range []bool{false, true} { for _, overlap := range []bool{false, true} {
t.Run(fmt.Sprint(overlap), func(t *testing.T) { t.Run(fmt.Sprint(overlap), func(t *testing.T) {
result, err := Load(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error { result, err := testLoad(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
c.M["new"] = map[string]any{"child": int64(7)} c.M["new"] = map[string]any{"child": int64(7)}
delete(c.M, "old") delete(c.M, "old")
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old")) r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"os" "os"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"os" "os"
@@ -26,7 +26,7 @@ items: {alpha: alpha}
var previous []SourceInfo var previous []SourceInfo
for run := 0; run < 2; run++ { for run := 0; run < 2; run++ {
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -68,7 +68,7 @@ items: {root: root}
writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n") writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n")
writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n") writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -79,7 +79,7 @@ items: {root: root}
ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n") ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n")
_ = ignoredPath _ = ignoredPath
plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n") plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n")
plain, err := Load[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true}) plain, err := testLoad[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" { if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" {
t.Fatalf("implicit discovery result = %#v, %v", plain, err) t.Fatalf("implicit discovery result = %#v, %v", plain, err)
} }
@@ -116,7 +116,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true) mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(result.Sources()) != 1 { if err != nil || len(result.Sources()) != 1 {
t.Fatalf("optional unmatched glob = %#v, %v", result, err) t.Fatalf("optional unmatched glob = %#v, %v", result, err)
} }
@@ -166,7 +166,7 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
t.Skipf("symlinks unavailable: %v", err) t.Skipf("symlinks unavailable: %v", err)
} }
mainPath := writeGlobRoot(t, root, "linked/*.yml", false) mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Items["linked"] != "linked" { if err != nil || result.Config.Items["linked"] != "linked" {
t.Fatalf("contained directory symlink = %#v, %v", result, err) t.Fatalf("contained directory symlink = %#v, %v", result, err)
} }
@@ -232,7 +232,7 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) { func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n") mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{ result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
Profile: "default", Filesystem: &FilesystemContext{Root: directory}, Profile: "default", Filesystem: &FilesystemContext{Root: directory},
}) })
if err != nil || result.Config.Items["root"] != "root" { if err != nil || result.Config.Items["root"] != "root" {
@@ -242,12 +242,12 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid) requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid)
input := Bytes("memory.yml", []byte("items: {root: memory}\n")) input := Bytes("memory.yml", []byte("items: {root: memory}\n"))
result, err = Load[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}}) result, err = testLoad[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
if err != nil || result.Config.Items["root"] != "memory" { if err != nil || result.Config.Items["root"] != "memory" {
t.Fatalf("byte filesystem context = %#v, %v", result, err) t.Fatalf("byte filesystem context = %#v, %v", result, err)
} }
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid) requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
defaultsOnly, err := Load[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"}) defaultsOnly, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
if err != nil || defaultsOnly == nil { if err != nil || defaultsOnly == nil {
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err) t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
} }
@@ -255,7 +255,7 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
selectedLink := filepath.Join(directory, "selected-link.yml") selectedLink := filepath.Join(directory, "selected-link.yml")
if err := os.Symlink(mainPath, selectedLink); err == nil { if err := os.Symlink(mainPath, selectedLink); err == nil {
linked, err := Load[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}}) linked, err := testLoad[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) { if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) {
t.Fatalf("selected symlink identity = %#v, %v", linked, err) t.Fatalf("selected symlink identity = %#v, %v", linked, err)
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -33,7 +33,7 @@ func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
for _, imports := range []bool{false, true} { for _, imports := range []bool{false, true} {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n") mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
result, err := Load[config](File(mainPath), Options[config]{Imports: imports}) result, err := testLoad[config](File(mainPath), Options[config]{Imports: imports})
if err != nil { if err != nil {
t.Fatalf("Imports=%v: %v", imports, err) t.Fatalf("Imports=%v: %v", imports, err)
} }
@@ -61,7 +61,7 @@ settings:
thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n") thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n")
secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n") secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -163,7 +163,7 @@ func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
optional: true optional: true
settings: {root: retained} settings: {root: retained}
`) `)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.Root != "retained" || len(result.Sources()) != 1 { if err != nil || result.Config.Settings.Root != "retained" || len(result.Sources()) != 1 {
t.Fatalf("optional result = %#v, %v", result, err) t.Fatalf("optional result = %#v, %v", result, err)
} }
@@ -253,7 +253,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
t.Skipf("symlinks unavailable: %v", err) t.Skipf("symlinks unavailable: %v", err)
} }
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) { if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
t.Fatalf("symlink result = %#v, %v", result, err) t.Fatalf("symlink result = %#v, %v", result, err)
} }
@@ -303,7 +303,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
{name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }}, {name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context}) result, err := testLoad[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context})
if err != nil || result.Config.Settings.First != "imported" { if err != nil || result.Config.Settings.First != "imported" {
t.Fatalf("explicit filesystem result = %#v, %v", result, err) t.Fatalf("explicit filesystem result = %#v, %v", result, err)
} }
@@ -318,7 +318,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n") mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n") writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{ result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
Imports: true, Filesystem: &FilesystemContext{Root: root}, Imports: true, Filesystem: &FilesystemContext{Root: root},
}) })
if err != nil || result.Config.Settings.Root != "shared" { if err != nil || result.Config.Settings.Root != "shared" {
@@ -455,7 +455,7 @@ func requireLoadCode(t *testing.T, source Source, options Options[importTestConf
func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError { func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError {
t.Helper() t.Helper()
result, err := Load[importTestConfig](source, options) result, err := testLoad[importTestConfig](source, options)
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err) t.Fatalf("result, error = %#v, %v", result, err)
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"
@@ -38,7 +38,7 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
} }
const count = 32 const count = 32
results := make([]*Result[independentConfig], count) results := make([]*testResult[independentConfig], count)
errorsByIndex := make([]error, count) errorsByIndex := make([]error, count)
var group sync.WaitGroup var group sync.WaitGroup
for index := range count { for index := range count {
@@ -46,14 +46,14 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
go func(index int) { go func(index int) {
defer group.Done() defer group.Done()
input := fmt.Sprintf("custom: value-%d\n", index) input := fmt.Sprintf("custom: value-%d\n", index)
results[index], errorsByIndex[index] = Load(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition}) results[index], errorsByIndex[index] = testLoad(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
}(index) }(index)
} }
group.Wait() group.Wait()
for index, err := range errorsByIndex { for index, err := range errorsByIndex {
if err != nil { if err != nil {
t.Fatalf("Load(%d) failed: %v", index, err) t.Fatalf("testLoad(%d) failed: %v", index, err)
} }
} }
results[0].Config.Labels["default"] = "mutated" results[0].Config.Labels["default"] = "mutated"
@@ -73,6 +73,29 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
} }
} }
func TestLoadedConfigurationIsSeparateFromInspection(t *testing.T) {
type configuration struct {
Value string `yaml:"value"`
}
loaded, err := Load[configuration](Bytes("config.yml", []byte("value: original\n")), testSecretBinding())
if err != nil {
t.Fatal(err)
}
if loaded.Inspection == nil {
t.Fatal("inspection is nil")
}
loaded.Config.Value = "mutated"
var effective bytes.Buffer
if err := loaded.Inspection.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if effective.String() != "value: original\n" {
t.Fatalf("inspection changed with configuration: %q", effective.String())
}
}
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) { func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
source := Reader("shared", strings.NewReader("value: one\n")) source := Reader("shared", strings.NewReader("value: one\n"))
copied := source copied := source
@@ -88,7 +111,7 @@ func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
go func(candidate Source) { go func(candidate Source) {
defer group.Done() defer group.Done()
<-start <-start
_, err := Load[config](candidate) _, err := testLoad[config](candidate)
errorsFound <- err errorsFound <- err
}(candidate) }(candidate)
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"os" "os"
@@ -44,8 +44,9 @@ type Override struct {
Flag string Flag string
} }
// Resolution exposes surviving-input presence and records audited application // Resolution exposes entry-state input provenance, surviving-input presence, and
// resolution changes. A resolver must not retain or concurrently use the context. // audited application resolution changes. A resolver must not retain or
// concurrently use the context.
type Resolution struct { type Resolution struct {
state *resolutionState state *resolutionState
} }
@@ -65,10 +66,15 @@ type Version struct {
Text string Text string
} }
// Result is a successful independently owned configuration load. // Loaded is a short-lived generic carrier for a successful configuration load.
type Result[T any] struct { // Inspection is frozen independently of Config and does not retain application values.
type Loaded[T any] struct {
Config T Config T
Inspection *Inspection
}
// Inspection owns frozen load snapshots and detached query state.
type Inspection struct {
metadata Metadata metadata Metadata
sources []SourceInfo sources []SourceInfo
diagnostics []Diagnostic diagnostics []Diagnostic
@@ -83,7 +89,7 @@ type Result[T any] struct {
} }
// Metadata returns detached effective presence, origin, and history records. // Metadata returns detached effective presence, origin, and history records.
func (r *Result[T]) Metadata() Metadata { func (r *Inspection) Metadata() Metadata {
if r == nil { if r == nil {
return Metadata{} return Metadata{}
} }
@@ -91,7 +97,7 @@ func (r *Result[T]) Metadata() Metadata {
} }
// Sources returns a detached list of sources used by the load. // Sources returns a detached list of sources used by the load.
func (r *Result[T]) Sources() []SourceInfo { func (r *Inspection) Sources() []SourceInfo {
if r == nil { if r == nil {
return nil return nil
} }
@@ -99,7 +105,7 @@ func (r *Result[T]) Sources() []SourceInfo {
} }
// Diagnostics returns detached nonfatal diagnostics from the load. // Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Result[T]) Diagnostics() []Diagnostic { func (r *Inspection) Diagnostics() []Diagnostic {
if r == nil { if r == nil {
return nil return nil
} }
@@ -109,11 +115,11 @@ func (r *Result[T]) Diagnostics() []Diagnostic {
// Load parses and strictly decodes zero or one selected source, explicitly enabled // 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 // imports, and one selected profile. It accepts at most one Options value and
// returns no result when any error is reported. // returns no result when any error is reported.
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Loaded[T], error) {
return loadWithSecretLookup(source, os.LookupEnv, supplied...) return loadWithSecretLookup(source, secrets, os.LookupEnv, supplied...)
} }
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
accumulator := diagnosticAccumulator{} accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied) options, diagnostics := selectOptions(source, supplied)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
@@ -127,7 +133,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
schema, schemaIssue := buildSchema(reflect.TypeFor[T]()) schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil { if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError() return nil, accumulator.loadError()

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"slices" "slices"
@@ -6,6 +6,47 @@ import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
// resolutionInput is the immutable entry-state provenance available to a
// resolution callback. It owns detached metadata and source records so later
// resolution updates cannot change the entry view.
type resolutionInput struct {
metadata Metadata
sources map[string]SourceInfo
}
func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo) resolutionInput {
frozen := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)),
effectivePaths: effectivePathIndex(final),
}
for pointer, info := range metadata.fields {
frozen.fields[pointer] = cloneFieldInfo(info)
}
indexed := make(map[string]SourceInfo, len(sources))
for _, source := range sources {
if source.ID == "" {
panic("configkit: source identity is missing")
}
if _, exists := indexed[source.ID]; exists {
panic("configkit: source identity is duplicated")
}
indexed[source.ID] = cloneSourceInfo(source)
}
return resolutionInput{metadata: frozen, sources: indexed}
}
func (input resolutionInput) lookup(path Path) (FieldInfo, bool) {
return input.metadata.Lookup(path)
}
func (input resolutionInput) source(id string) (SourceInfo, bool) {
source, ok := input.sources[id]
if !ok {
return SourceInfo{}, false
}
return cloneSourceInfo(source), true
}
func metadataFromDecoded(decoded decodedConfiguration) Metadata { func metadataFromDecoded(decoded decodedConfiguration) Metadata {
metadata := Metadata{ metadata := Metadata{
fields: make(map[string]FieldInfo), fields: make(map[string]FieldInfo),

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"reflect" "reflect"
@@ -51,7 +51,8 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
final := decoded.final final := decoded.final
var resolutionSnapshots []resolutionSnapshot var resolutionSnapshots []resolutionSnapshot
if definition.Resolve != nil { if definition.Resolve != nil {
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document) entry := newResolutionInput(metadata, decoded.final, input.sources)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
diagnostics = append(diagnostics, resolved.diagnostics...) diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) { if hasErrors(resolved.diagnostics) {
return resolvedLoad[T]{}, diagnostics return resolvedLoad[T]{}, diagnostics
@@ -79,7 +80,7 @@ func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, m
return detached.Interface().(T), nil return detached.Interface().(T), nil
} }
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Result[T], error) { func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Loaded[T], error) {
// A read-only validator can replace a value with an equal borrowed object. // A read-only validator can replace a value with an equal borrowed object.
// Detach without re-reading the environment or discarding validated state. // Detach without re-reading the environment or discarding validated state.
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets) configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets)
@@ -91,8 +92,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
return &Result[T]{ inspection := &Inspection{
Config: configuration,
metadata: metadata, metadata: metadata,
sources: cloneSourceInfos(accumulator.sources), sources: cloneSourceInfos(accumulator.sources),
diagnostics: canonicalDiagnostics(accumulator.diagnostics), diagnostics: canonicalDiagnostics(accumulator.diagnostics),
@@ -104,7 +104,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
applications: applications, applications: applications,
secrets: secrets, secrets: secrets,
secretReferences: cloneSecretReferences(references), secretReferences: cloneSecretReferences(references),
}, nil }
return &Loaded[T]{Config: configuration, Inspection: inspection}, nil
} }
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -38,7 +38,7 @@ func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var defaultsCalls atomic.Int32 var defaultsCalls atomic.Int32
result, err := Load(test.source(), Options[config]{Definition: Definition[config]{ result, err := testLoad(test.source(), Options[config]{Definition: Definition[config]{
Defaults: func() config { Defaults: func() config {
defaultsCalls.Add(1) defaultsCalls.Add(1)
return config{Name: "default"} return config{Name: "default"}
@@ -92,7 +92,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
validation.Warning(versionPath, "review schema compatibility", Keys("missing_related")) validation.Warning(versionPath, "review schema compatibility", Keys("missing_related"))
return nil return nil
}} }}
result, err := Load(test.source, Options[config]{Definition: Definition[config]{Version: rule}}) result, err := testLoad(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
if test.wantErr != "" { if test.wantErr != "" {
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)
@@ -144,21 +144,21 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
load func(Source, *VersionRule) (string, error) load func(Source, *VersionRule) (string, error)
}{ }{
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}}) result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
return result.Config.Version, nil return result.Config.Version, nil
}}, }},
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}}) result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
return result.Config.Version, nil return result.Config.Version, nil
}}, }},
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}}) result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -168,7 +168,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
return *result.Config.Version, nil return *result.Config.Version, nil
}}, }},
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}}) result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -194,7 +194,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
}) })
} }
if result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil { if result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
t.Fatalf("load without rule = %#v, %v", result, err) t.Fatalf("load without rule = %#v, %v", result, err)
} else { } else {
var loadError *LoadError var loadError *LoadError
@@ -207,7 +207,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
Label string `yaml:"label"` Label string `yaml:"label"`
} }
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }} rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }}
if result, err := Load(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil { if result, err := testLoad(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
t.Fatalf("unrelated coercion = %#v, %v", result, err) t.Fatalf("unrelated coercion = %#v, %v", result, err)
} else { } else {
var loadError *LoadError var loadError *LoadError
@@ -240,7 +240,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
checked = version checked = version
return nil return nil
}} }}
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}}) result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -266,7 +266,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
called = true called = true
return nil return nil
}} }}
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}}) result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
if result != nil || called { if result != nil || called {
t.Fatalf("result = %#v, checker called = %v", result, called) t.Fatalf("result = %#v, checker called = %v", result, called)
} }
@@ -289,7 +289,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
} }
return nil return nil
}} }}
result, err := Load(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}}) result, err := testLoad(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -299,7 +299,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
type sequenceConfig struct { type sequenceConfig struct {
Versions []namedVersion `yaml:"versions"` Versions []namedVersion `yaml:"versions"`
} }
sequenceResult, err := Load(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}}) sequenceResult, err := testLoad(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}})
if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" { if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" {
t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err) t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err)
} }
@@ -311,13 +311,13 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
escapedResult, err := Load(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}}) escapedResult, err := testLoad(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}})
if err != nil || escapedResult.Config.Versions["a/b"] != "9" { if err != nil || escapedResult.Config.Versions["a/b"] != "9" {
t.Fatalf("escaped configuration = %#v, %v", escapedResult, err) t.Fatalf("escaped configuration = %#v, %v", escapedResult, err)
} }
siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }} siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }}
if result, err := Load(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil { if result, err := testLoad(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil {
t.Fatalf("same-type sibling result = %#v, %v", result, err) t.Fatalf("same-type sibling result = %#v, %v", result, err)
} else { } else {
requireDiagnostic(t, err, CodeDecodeType, "/versions/other") requireDiagnostic(t, err, CodeDecodeType, "/versions/other")
@@ -332,16 +332,16 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
type config struct { type config struct {
Version int8 `yaml:"version"` Version int8 `yaml:"version"`
} }
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if err != nil || result.Config.Version != 4 { if err != nil || result.Config.Version != 4 {
t.Fatalf("result = %#v, %v", result, err) t.Fatalf("result = %#v, %v", result, err)
} }
result, err = Load(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err = testLoad(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if result != nil { if result != nil {
t.Fatalf("range result = %#v", result) t.Fatalf("range result = %#v", result)
} }
requireDiagnostic(t, err, CodeDecodeRange, "/version") requireDiagnostic(t, err, CodeDecodeRange, "/version")
result, err = Load(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err = testLoad(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if result != nil { if result != nil {
t.Fatalf("quoted integer result = %#v", result) t.Fatalf("quoted integer result = %#v", result)
} }
@@ -352,11 +352,11 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
type config struct { type config struct {
Version any `yaml:"version"` Version any `yaml:"version"`
} }
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if err != nil || result.Config.Version != int64(4) { if err != nil || result.Config.Version != int64(4) {
t.Fatalf("result = %#v, %v", result, err) t.Fatalf("result = %#v, %v", result, err)
} }
result, err = Load(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err = testLoad(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if result != nil { if result != nil {
t.Fatalf("range result = %#v", result) t.Fatalf("range result = %#v", result)
} }
@@ -371,21 +371,21 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
type config struct { type config struct {
Version validatedText `yaml:"version"` Version validatedText `yaml:"version"`
} }
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) _, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
return err return err
}}, }},
{name: "duration", load: func() error { {name: "duration", load: func() error {
type config struct { type config struct {
Version time.Duration `yaml:"version"` Version time.Duration `yaml:"version"`
} }
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) _, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
return err return err
}}, }},
{name: "secret", load: func() error { {name: "secret", load: func() error {
type config struct { type config struct {
Version Secret `yaml:"version"` Version Secret `yaml:"version"`
} }
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) _, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
return err return err
}}, }},
} { } {
@@ -409,7 +409,7 @@ func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) {
validation.Error(Keys("version"), "unsupported configuration version") validation.Error(Keys("version"), "unsupported configuration version")
return nil return nil
}} }}
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { defaultsCalled = true; return config{} }, Defaults: func() config { defaultsCalled = true; return config{} },
Version: rule, Version: rule,
Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil }, Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil },
@@ -440,7 +440,7 @@ func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
Name string `yaml:"name"` Name string `yaml:"name"`
} }
var defaultsCalls atomic.Int32 var defaultsCalls atomic.Int32
result, err := Load(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { Defaults: func() config {
defaultsCalls.Add(1) defaultsCalls.Add(1)
return config{Name: "default"} return config{Name: "default"}
@@ -474,7 +474,7 @@ func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
Nested nested `yaml:"nested"` Nested nested `yaml:"nested"`
Nullable map[string]string `yaml:"nullable"` Nullable map[string]string `yaml:"nullable"`
} }
result, err := Load(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { Defaults: func() config {
return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}} return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}}
}, },
@@ -538,7 +538,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(test.source, test.options...) result, err := testLoad(test.source, test.options...)
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)
} }
@@ -549,7 +549,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
}) })
} }
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n"))) result, err := testLoad[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)
} }
@@ -577,9 +577,9 @@ func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) {
run func() (any, error) run func() (any, error)
code Code code Code
}{ }{
{name: "source", run: func() (any, error) { return Load[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing}, {name: "source", run: func() (any, error) { return testLoad[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing},
{name: "schema", run: func() (any, error) { return Load[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType}, {name: "schema", run: func() (any, error) { return testLoad[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
{name: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField}, {name: "decode", run: func() (any, error) { return testLoad[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

149
internal/engine/metadata.go Normal file
View File

@@ -0,0 +1,149 @@
package engine
import (
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths.
type Metadata struct {
effectivePaths map[string]Path
historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
}
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
if !ok {
return FieldInfo{}, false
}
return cloneFieldInfo(info), true
}
// IsExplicit reports whether a surviving input contribution explicitly defined
// path.
func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path)
return ok && info.Explicit
}
// History returns detached definition and resolution records for path. A record's
// Path can be an ancestor when that container operation replaced or removed path.
// Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change {
if !m.hasHistoricalPath(path) {
return nil
}
history, ok := m.history[path.String()]
if !ok {
return nil
}
cloned := make([]Change, len(history))
for i, change := range history {
cloned[i] = cloneChange(change)
}
return cloned
}
// HistoricalPaths returns the paths observed during loading, including effective
// paths and paths removed by overlays or recorded resolution. It includes default
// paths and the root, but not paths that never existed. The zero Metadata returns
// an empty slice.
//
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
// one pointer can identify a mapping key in an earlier layer and a sequence index
// in a later one. Use History to inspect definitions and Lookup to check whether
// a path remains effective. Enumeration exposes no configuration values and does
// not perform another load or resolve secrets.
func (m Metadata) HistoricalPaths() []Path {
pointers := make([]string, 0, len(m.historicalPaths))
for pointer := range m.historicalPaths {
pointers = append(pointers, pointer)
}
sort.Strings(pointers)
paths := make([]Path, 0, len(pointers))
for _, pointer := range pointers {
// The index contains only canonical pointers produced by Path.String.
path, _ := ParsePath(pointer)
paths = append(paths, path)
}
return paths
}
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers.
func matchesPathShape(query, candidate Path) bool {
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
return false
}
for i, segment := range query.segments {
actual := candidate.segments[i]
if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment)
if !ok || index != actual.index {
return false
}
} else if segment.kind == pathSegmentIndex {
return false
}
}
return true
}
func (m Metadata) hasHistoricalPath(path Path) bool {
if !path.valid() {
return false
}
for _, candidate := range m.historicalPaths[path.String()] {
if matchesPathShape(path, candidate) {
return true
}
}
return false
}
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
m.effectivePaths = effectivePathIndex(final)
m.historicalPaths = make(map[string][]Path)
add := func(path Path, _ *document.Node) {
pointer := path.String()
for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) {
return
}
}
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
}
walkDocument(final, Path{}, add)
for _, tree := range earlier {
walkDocument(tree, Path{}, add)
}
for _, application := range applications {
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
prefix := Path{}
for _, segment := range application.path.segments {
prefix = prefix.Key(segment.key)
}
walkDocument(application.value, prefix, add)
}
}
// effectivePathIndex records document-derived paths with their contextual segment
// kinds. Callers retain no aliases to the walked tree or its paths.
func effectivePathIndex(final *document.Node) map[string]Path {
paths := make(map[string]Path)
walkDocument(final, Path{}, func(path Path, _ *document.Node) {
paths[path.String()] = path.clone()
})
return paths
}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"
@@ -8,7 +8,7 @@ import (
func TestMetadataHistoricalPaths(t *testing.T) { func TestMetadataHistoricalPaths(t *testing.T) {
t.Parallel() t.Parallel()
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{ result, err := testLoad[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
Overrides: []Override{ Overrides: []Override{
{Path: Keys("retired"), Value: nil, Flag: "--retired"}, {Path: Keys("retired"), Value: nil, Flag: "--retired"},
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"}, {Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
@@ -61,7 +61,7 @@ func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n") writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n") writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n") writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"}) result, err := testLoad[config](File(root), Options[config]{Imports: true, Profile: "testing"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -148,8 +148,91 @@ func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
} }
} }
func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
t.Parallel()
type configuration struct {
Default string `yaml:"default"`
Items []string `yaml:"items"`
Values map[string]string `yaml:"values"`
Nullable *string `yaml:"nullable"`
}
schema := mustSchemaFor[configuration](t)
document := parseDocumentForDecode(t, "items: [first]\nvalues: {'0': key, 'a/b~': escaped}\nnullable: null\n")
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(configuration{Default: "retained"}), document)
if len(issues) != 0 {
t.Fatalf("decode issues = %#v", issues)
}
metadata := metadataFromDecoded(decoded)
input := newResolutionInput(metadata, decoded.final, []SourceInfo{{
ID: "source-1", Label: "decode.yml", Layer: LayerBase,
ImportedFrom: &Location{SourceID: "parent", Line: 1, Column: 1},
}})
parsedItem, err := ParsePath("/items/0")
if err != nil {
t.Fatal(err)
}
parsedKey, err := ParsePath("/values/0")
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Path{}, Keys("default"), Keys("nullable"), parsedItem, parsedKey, Keys("values", "a/b~")} {
if _, ok := input.lookup(path); !ok {
t.Fatalf("entry path %s is missing", path)
}
}
defaultInfo, _ := input.lookup(Keys("default"))
nullInfo, _ := input.lookup(Keys("nullable"))
if defaultInfo.Explicit || !nullInfo.Explicit || !nullInfo.Null {
t.Fatalf("default = %#v, nullable = %#v", defaultInfo, nullInfo)
}
for _, path := range []Path{
Keys("default", "child"),
Keys("items", "0"),
Keys("values").Index(0),
Keys("items").Index(1),
} {
if _, ok := input.lookup(path); ok {
t.Fatalf("entry lookup accepted %s", path)
}
}
item, _ := input.lookup(parsedItem)
item.Origin.Locations[0].SourceID = "mutated"
source, ok := input.source("source-1")
if !ok {
t.Fatal("source is missing")
}
source.ImportedFrom.SourceID = "mutated"
configurationAtEntry := decoded.value.Interface().(configuration)
resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error {
value.Items[0] = "normalized"
resolution.Record(Keys("items"), ChangeNormalization, "normalized item")
return nil
}, schema, decoded, document, input)
if len(resolved.diagnostics) != 0 {
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
}
applyResolutionMetadata(&metadata, resolved)
metadata.effectivePaths = effectivePathIndex(resolved.final)
again, ok := input.lookup(parsedItem)
if !ok || again.Origin.Locations[0].SourceID != "source-1" || again.Origin.Operation != "" {
t.Fatalf("entry metadata changed after resolution = %#v, %v", again, ok)
}
againSource, ok := input.source("source-1")
if !ok || againSource.ImportedFrom == nil || againSource.ImportedFrom.SourceID != "parent" {
t.Fatalf("entry source changed = %#v, %v", againSource, ok)
}
if _, ok := input.source(""); ok {
t.Fatal("empty source identity resolved")
}
}
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) { func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
result, err := Load[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}}) result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) { func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
if len(supplied) > 1 { if len(supplied) > 1 {

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"slices" "slices"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"reflect" "reflect"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"slices" "slices"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -56,7 +56,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
{Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"}, {Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"},
{Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"}, {Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"},
} }
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) { result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "CLI_SECRET" { if name != "CLI_SECRET" {
t.Fatalf("looked up superseded secret name %q", name) t.Fatalf("looked up superseded secret name %q", name)
} }
@@ -83,7 +83,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
}, },
}) })
if err != nil { if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err) t.Fatalf("testLoadWithSecretLookup() error = %v", err)
} }
want := overrideLifecycleSettings{ want := overrideLifecycleSettings{
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"}, Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
@@ -123,7 +123,7 @@ func TestOverridesSupportDefaultsOnlyMapMergeListReplacementAndNumericKeys(t *te
"int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false, "int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false,
"null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"}, "null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"},
} }
result, err := Load(NoSource(), Options[config]{ result, err := testLoad(NoSource(), Options[config]{
Definition: Definition[config]{Defaults: func() config { Definition: Definition[config]{Defaults: func() config {
return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}} return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}}
}}, }},
@@ -194,7 +194,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel}, {name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides}) result, err := testLoad(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
if result != nil { if result != nil {
t.Fatalf("result = %#v, want nil", result) t.Fatalf("result = %#v, want nil", result)
} }
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
type config struct { type config struct {
Schema schemaConfig `yaml:"schema"` Schema schemaConfig `yaml:"schema"`
} }
_, err := Load(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{ _, err := testLoad(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}}, Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}}, Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
}) })
@@ -224,7 +224,7 @@ func TestOverrideDecodeFailureUsesFlagSourceWithoutRawValue(t *testing.T) {
type config struct { type config struct {
Count int `yaml:"count"` Count int `yaml:"count"`
} }
result, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{ result, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing", Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing",
}}}) }}})
if result != nil { if result != nil {
@@ -249,7 +249,7 @@ func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) {
type config struct { type config struct {
Count int `yaml:"count"` Count int `yaml:"count"`
} }
_, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{ _, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("count"), Value: "not-an-integer", Flag: "--count", Path: Keys("count"), Value: "not-an-integer", Flag: "--count",
}}}) }}})
loadError := requireSecretLoadCode(t, err, CodeDecodeType) loadError := requireSecretLoadCode(t, err, CodeDecodeType)
@@ -262,7 +262,7 @@ func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
type config struct { type config struct {
Value string `yaml:"value"` Value string `yaml:"value"`
} }
result, err := Load(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}}) result, err := testLoad(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"math" "math"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"strings" "strings"

148
internal/engine/path.go Normal file
View File

@@ -0,0 +1,148 @@
package engine
import (
"errors"
"strconv"
"strings"
)
// Path identifies a configuration value using YAML field names. Its textual form
// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1.
// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot,
// percent-containing, and numeric keys retain their literal meaning. Parsed
// numeric tokens are resolved only when traversal knows their containing type.
//
// Paths are immutable. The zero value is the root path.
type Path struct {
segments []pathSegment
invalid bool
}
type pathSegmentKind uint8
const (
pathSegmentKey pathSegmentKind = iota
pathSegmentIndex
pathSegmentParsed
)
type pathSegment struct {
kind pathSegmentKind
key string
index int
}
// Keys constructs a path of mapping keys. Keys with no arguments returns the
// root path.
func Keys(keys ...string) Path {
p := Path{}
for _, key := range keys {
p = p.Key(key)
}
return p
}
// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema
// or document traversal determines whether the containing value is a mapping or
// sequence.
func ParsePath(pointer string) (Path, error) {
if pointer == "" {
return Path{}, nil
}
if pointer[0] != '/' {
return Path{}, errors.New("configkit: path must be empty or begin with slash")
}
parts := strings.Split(pointer[1:], "/")
segments := make([]pathSegment, 0, len(parts))
for _, part := range parts {
key, ok := unescapePathToken(part)
if !ok {
return Path{}, errors.New("configkit: path contains an invalid tilde escape")
}
segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key})
}
return Path{segments: segments}, nil
}
// Key returns a new path with key appended as a mapping-key segment.
func (p Path) Key(key string) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentKey, key: key})
return Path{segments: segments, invalid: p.invalid}
}
// Index returns a new path with index appended as a sequence-index segment.
// A negative index produces an invalid path that configuration operations reject.
func (p Path) Index(index int) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index})
return Path{segments: segments, invalid: p.invalid || index < 0}
}
// String returns the path's canonical JSON Pointer spelling.
func (p Path) String() string {
if len(p.segments) == 0 {
return ""
}
var b strings.Builder
for _, segment := range p.segments {
b.WriteByte('/')
if segment.kind == pathSegmentIndex {
b.WriteString(strconv.Itoa(segment.index))
continue
}
b.WriteString(escapePathToken(segment.key))
}
return b.String()
}
func (p Path) clone() Path {
return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid}
}
func (p Path) valid() bool {
return !p.invalid
}
func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
if len(segments) == 0 && extra == 0 {
return nil
}
cloned := make([]pathSegment, len(segments), len(segments)+extra)
copy(cloned, segments)
return cloned
}
func escapePathToken(token string) string {
token = strings.ReplaceAll(token, "~", "~0")
return strings.ReplaceAll(token, "/", "~1")
}
func unescapePathToken(token string) (string, bool) {
if !strings.Contains(token, "~") {
return token, true
}
var b strings.Builder
for i := 0; i < len(token); i++ {
if token[i] != '~' {
b.WriteByte(token[i])
continue
}
if i+1 >= len(token) {
return "", false
}
i++
switch token[i] {
case '0':
b.WriteByte('~')
case '1':
b.WriteByte('/')
default:
return "", false
}
}
return b.String(), true
}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "testing" import "testing"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "testing" import "testing"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -40,7 +40,7 @@ nullable: null
value: scalar value: scalar
`) `)
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{ result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{
Imports: true, Imports: true,
Profile: "testing", Profile: "testing",
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig { Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
@@ -101,12 +101,12 @@ func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n") writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
for _, name := range []string{"", "default"} { for _, name := range []string{"", "default"} {
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name}) result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 { if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
t.Fatalf("inactive profile %q = %#v, %v", name, result, err) t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
} }
} }
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"}) selected, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 { if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
t.Fatalf("selected profile = %#v, %v", selected, err) t.Fatalf("selected profile = %#v, %v", selected, err)
} }
@@ -150,7 +150,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n") mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n") writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}) result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
if err != nil || result.Config.Schema["other"] != "allowed" { if err != nil || result.Config.Schema["other"] != "allowed" {
t.Fatalf("mapping sibling = %#v, %v", result, err) t.Fatalf("mapping sibling = %#v, %v", result, err)
} }
@@ -201,7 +201,7 @@ func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
t.Run("byte source context", func(t *testing.T) { t.Run("byte source context", func(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n") writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{ result, err := testLoad[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}, Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
}) })
if err != nil || result.Config.Settings.Changed != "selected" { if err != nil || result.Config.Settings.Changed != "selected" {
@@ -251,7 +251,7 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError { func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
t.Helper() t.Helper()
result, err := Load[profileTestConfig](source, options) result, err := testLoad[profileTestConfig](source, options)
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err) t.Fatalf("result, error = %#v, %v", result, err)
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"cmp" "cmp"
@@ -199,7 +199,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
case descriptorSecret: case descriptorSecret:
node, issue := o.node(document.String, path, depth) node, issue := o.node(document.String, path, depth)
if issue == nil { if issue == nil {
node.Text = value.Interface().(Secret).EnvName() node.Text = o.schema.secrets.Read(value).Name
} }
return node, issue return node, issue
case descriptorText: case descriptorText:

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -359,9 +359,9 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor { func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
t.Helper() t.Helper()
schema, issue := buildSchema(reflect.TypeFor[T]()) schema, issue := testBuildSchema(reflect.TypeFor[T]())
if issue != nil { if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue) t.Fatalf("testBuildSchema() issue = %#v", issue)
} }
return schema return schema
} }

View File

@@ -0,0 +1,510 @@
package engine
import (
"errors"
"reflect"
"strings"
"sync"
"testing"
)
type layeredItem struct {
Value string `yaml:"value"`
}
type layeredConfig struct {
Shared struct {
Equal string `yaml:"equal"`
Imported string `yaml:"imported"`
Profile string `yaml:"profile"`
} `yaml:"shared"`
Values []int `yaml:"values"`
Items map[string]layeredItem `yaml:"items"`
Value any `yaml:"value"`
Nullable *string `yaml:"nullable"`
Derived int `yaml:"derived"`
Empty map[string]string `yaml:"empty"`
}
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: imported.yml}]
shared: {equal: same}
values: [1, 2]
items:
keep: {value: 'yes'}
remove: {value: 'no'}
value: {child: base}
nullable: base
empty: {}
`)
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `shared: {profile: selected}
values: [3, 4]
value: selected
nullable: null
empty: {}
`)
defaultNullable := "default"
definition := Definition[layeredConfig]{
Defaults: func() layeredConfig {
defaults := layeredConfig{
Values: []int{0, 0}, Value: map[string]any{"child": "default"},
Nullable: &defaultNullable, Empty: map[string]string{"retained": "default"},
}
defaults.Shared.Equal = "same"
return defaults
},
Resolve: func(configuration *layeredConfig, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"), Keys("shared", "imported"))
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"))
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled items")
Default(resolution, Keys("derived"), &configuration.Derived, 7,
"derived from equal", Keys("shared", "equal"), Keys("shared", "equal"))
resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported"))
return nil
},
}
result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{
Imports: true, Profile: "testing", Definition: definition,
})
if err != nil {
var loadError *LoadError
_ = errors.As(err, &loadError)
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
}
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, profilePath)}
sources := result.Sources()
gotSources := make([]string, len(sources))
for index, source := range sources {
gotSources[index] = source.Path
}
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Profile != "selected" ||
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
result.Config.Empty["retained"] != "default" {
t.Fatalf("configuration = %#v", result.Config)
}
metadata := result.Metadata()
equal, _ := metadata.Lookup(Keys("shared", "equal"))
derived, _ := metadata.Lookup(Keys("derived"))
nullable, _ := metadata.Lookup(Keys("nullable"))
empty, _ := metadata.Lookup(Keys("empty"))
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerProfile ||
!empty.Explicit || empty.Origin.Layer != LayerProfile || len(empty.Origin.Locations) != 2 ||
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
}
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
t.Fatal("profile-removed child remains effective")
}
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
t.Fatal("resolution-removed sequence index remains effective")
}
if _, ok := metadata.Lookup(Keys("items", "remove", "value")); ok {
t.Fatal("resolution-removed mapping child remains effective")
}
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
[]Layer{LayerDefault, LayerBase, LayerProfile, LayerResolution},
[]string{"/values/1", "/values/1", "/values", "/values"})
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
[]Layer{LayerDefault, LayerBase, LayerProfile},
[]string{"/value/child", "/value/child", "/value"})
removedHistory := metadata.History(Keys("items", "remove", "value"))
assertHistoryLayersAndPaths(t, removedHistory,
[]Layer{LayerBase, LayerResolution},
[]string{"/items/remove/value", "/items"})
if !reflect.DeepEqual(removedHistory[1].Reasons, []string{"removed disabled items"}) {
t.Fatalf("removed history = %#v", removedHistory)
}
valuesHistory := metadata.History(Keys("values").Index(0))
lastValue := valuesHistory[len(valuesHistory)-1]
if !reflect.DeepEqual(lastValue.Reasons, []string{"bounded values"}) || len(lastValue.Dependencies) != 1 || lastValue.Dependencies[0].String() != "/shared/imported" {
t.Fatalf("canonical resolution history = %#v", lastValue)
}
schema, issue := testBuildSchema(reflect.TypeFor[layeredConfig]())
if issue != nil {
t.Fatal(issue)
}
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
}
metadata.history[Keys("values").Index(0).String()][0].Origin.Layer = "mutated"
sources[1].ImportedFrom.SourceID = "mutated"
diagnostics := result.Diagnostics()
diagnostics[0].Related[0].SourceID = "mutated"
result.Config.Values[0] = 99
freshHistory := result.Metadata().History(Keys("values").Index(0))
if freshHistory[0].Origin.Layer == "mutated" || result.Sources()[1].ImportedFrom.SourceID == "mutated" ||
result.Diagnostics()[0].Related[0].SourceID == "mutated" {
t.Fatal("returned inspection records alias private state")
}
snapshotValue, ok := documentAtPath(result.final, Keys("values").Index(0))
if !ok || snapshotValue.Text != "5" {
t.Fatal("caller configuration mutation changed the private final snapshot")
}
}
func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
writeImportFile(t, directory, "profiles/testing.yml", "values: [3, 4]\n")
options := Options[layeredConfig]{Profile: "testing"}
const loads = 16
results := make(chan *testResult[layeredConfig], loads)
errors := make(chan error, loads)
var group sync.WaitGroup
for index := 0; index < loads; index++ {
group.Add(1)
go func() {
defer group.Done()
result, err := testLoad[layeredConfig](File(mainPath), options)
if err != nil {
errors <- err
return
}
results <- result
}()
}
group.Wait()
close(results)
close(errors)
for err := range errors {
t.Fatal(err)
}
var collected []*testResult[layeredConfig]
for result := range results {
collected = append(collected, result)
}
if len(collected) != loads {
t.Fatalf("completed loads = %d", len(collected))
}
collected[0].Config.Values[0] = 99
collected[0].metadata.fields[Keys("values").String()] = FieldInfo{}
for _, result := range collected[1:] {
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
}
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerProfile {
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
}
}
}
func TestResolutionInputKeepsLayeredOriginsAndSources(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: first.yml}, {path: second.yml}]
main: main
equal: same
cli_equal: same
composite: {main: main}
`)
firstPath := writeImportFile(t, directory, "first.yml", `imported: imported
sequence: [first, second]
composite: {first: first}
`)
secondPath := writeImportFile(t, directory, "second.yml", "composite: {second: second}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", "equal: same\nprofile: profile\n")
compositeSources := map[string]bool{
canonicalTestPath(t, mainPath): true,
canonicalTestPath(t, firstPath): true,
canonicalTestPath(t, secondPath): true,
}
type config struct {
Main string `yaml:"main"`
Imported string `yaml:"imported"`
Sequence []string `yaml:"sequence"`
Composite map[string]string `yaml:"composite"`
Equal string `yaml:"equal"`
Profile string `yaml:"profile"`
CLIEqual string `yaml:"cli_equal"`
}
var callbackErr error
result, err := testLoad[config](File(mainPath), Options[config]{
Imports: true, Profile: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}},
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
check := func(ok bool, message string) {
if !ok && callbackErr == nil {
callbackErr = errors.New(message)
}
}
fileOwner := func(path Path, want string) FieldInfo {
info, found := resolution.InputInfo(path)
check(found && len(info.Origin.Locations) == 1, "scalar has no exact origin")
if !found || len(info.Origin.Locations) != 1 {
return FieldInfo{}
}
source, exists := resolution.Source(info.Origin.Locations[0].SourceID)
check(exists && source.Path == want, "scalar source does not match its declaration")
return info
}
fileOwner(Keys("main"), canonicalTestPath(t, mainPath))
fileOwner(Keys("imported"), canonicalTestPath(t, firstPath))
fileOwner(Keys("sequence").Index(1), canonicalTestPath(t, firstPath))
profile := fileOwner(Keys("equal"), canonicalTestPath(t, profilePath))
fileOwner(Keys("profile"), canonicalTestPath(t, profilePath))
check(profile.Origin.Layer == LayerProfile, "profile replacement retained base ownership")
cli, found := resolution.InputInfo(Keys("cli_equal"))
check(found && cli.Origin.Layer == LayerCLI && len(cli.Origin.Locations) == 1, "CLI replacement lost ownership")
if found && len(cli.Origin.Locations) == 1 {
source, exists := resolution.Source(cli.Origin.Locations[0].SourceID)
check(exists && source.Layer == LayerCLI && source.Path == "", "CLI source has a file path or is missing")
}
composite, found := resolution.InputInfo(Keys("composite"))
check(found && len(composite.Origin.Locations) == 3, "composite mapping did not retain every contributor")
for _, location := range composite.Origin.Locations {
source, exists := resolution.Source(location.SourceID)
check(exists && compositeSources[source.Path], "composite location has no expected source record")
delete(compositeSources, source.Path)
}
check(len(compositeSources) == 0, "composite sources are incomplete")
imported, _ := resolution.InputInfo(Keys("imported"))
importedSourceID := imported.Origin.Locations[0].SourceID
imported.Origin.Locations[0].SourceID = "mutated"
importSource, _ := resolution.Source(importedSourceID)
check(importSource.ImportedFrom != nil, "import source is missing its import location")
if importSource.ImportedFrom != nil {
importSource.ImportedFrom.SourceID = "mutated"
}
again, _ := resolution.InputInfo(Keys("imported"))
againSource, _ := resolution.Source(importedSourceID)
check(again.Origin.Locations[0].SourceID == importedSourceID, "input record is aliased")
check(againSource.ImportedFrom != nil && againSource.ImportedFrom.SourceID == "source-1", "source record is aliased")
return callbackErr
}},
})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
if result.Config.Main != "main" || result.Config.Imported != "imported" || !reflect.DeepEqual(result.Config.Sequence, []string{"first", "second"}) ||
result.Config.Equal != "same" || result.Config.Profile != "profile" || result.Config.CLIEqual != "same" {
t.Fatalf("configuration = %#v", result.Config)
}
resultField, _ := result.Metadata().Lookup(Keys("imported"))
resultSource := result.Sources()[1]
if resultField.Origin.Locations[0].SourceID != "source-2" || resultSource.ImportedFrom == nil || resultSource.ImportedFrom.SourceID != "source-1" {
t.Fatal("callback record mutation changed the completed result")
}
}
func TestResolutionInputUsesEntryShapesAcrossTypedAndDynamicValues(t *testing.T) {
t.Parallel()
type Embedded struct {
Name string `yaml:"name"`
}
type config struct {
Embedded `yaml:",inline"`
Mappings map[string]string `yaml:"mappings"`
Array [2]string `yaml:"array"`
Dynamic any `yaml:"dynamic"`
}
var callbackErr error
_, err := testLoad[config](Bytes("shapes.yml", []byte("name: inlined\nmappings: {'-0': key, 'a/b~': escaped}\narray: [zero, one]\ndynamic: {nested: [dynamic]}\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
check := func(path Path, want bool) {
_, found := resolution.InputInfo(path)
if found != want && callbackErr == nil {
callbackErr = errors.New("entry shape lookup disagrees with its document container")
}
}
parsedMapping, _ := ParsePath("/mappings/-0")
escapedMapping, _ := ParsePath("/mappings/a~1b~0")
check(Keys("name"), true)
check(parsedMapping, true)
check(escapedMapping, true)
check(Keys("mappings").Index(0), false)
check(Keys("array").Index(1), true)
check(Keys("array", "1"), false)
check(Keys("array").Index(2), false)
check(Keys("dynamic", "nested").Index(0), true)
check(Keys("dynamic", "nested", "0"), false)
negative, _ := ParsePath("/array/-1")
check(negative, false)
return callbackErr
}}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
}
func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T) {
directory := t.TempDir()
importPath := writeImportFile(t, directory, "fragment.yml", "imported: file\n")
type config struct {
Root string `yaml:"root"`
Imported string `yaml:"imported"`
Default string `yaml:"default"`
}
input := "configkit: {imports: [{path: fragment.yml}]}\nroot: memory\n"
context := &FilesystemContext{BaseDir: directory, Root: directory}
for _, test := range []struct {
name string
source func() Source
}{
{name: "bytes", source: func() Source { return Bytes("memory://config.yml", []byte(input)) }},
{name: "reader", source: func() Source { return Reader("memory://config.yml", strings.NewReader(input)) }},
} {
t.Run(test.name, func(t *testing.T) {
var callbackErr error
_, err := testLoad[config](test.source(), Options[config]{Imports: true, Filesystem: context, Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
root, _ := resolution.InputInfo(Keys("root"))
imported, _ := resolution.InputInfo(Keys("imported"))
defaulted, _ := resolution.InputInfo(Keys("default"))
rootSource, rootFound := resolution.Source(root.Origin.Locations[0].SourceID)
importSource, importFound := resolution.Source(imported.Origin.Locations[0].SourceID)
if !rootFound || rootSource.Path != "" || !importFound || importSource.Path != canonicalTestPath(t, importPath) || defaulted.Explicit {
callbackErr = errors.New("non-file root or imported file provenance is wrong")
}
if _, found := resolution.Source("unknown"); found {
callbackErr = errors.New("unknown source was found")
}
return callbackErr
},
}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
})
}
var callbackErr error
_, err := testLoad[config](Bytes("memory://standalone.yml", []byte("root: memory\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
root, rootFound := resolution.InputInfo(Keys("root"))
defaulted, defaultFound := resolution.InputInfo(Keys("default"))
source, sourceFound := resolution.Source(root.Origin.Locations[0].SourceID)
if !rootFound || !defaultFound || !sourceFound || source.Path != "" || defaulted.Explicit {
callbackErr = errors.New("standalone non-file provenance is wrong")
}
return callbackErr
},
}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
}
func TestResolutionInputRemainsFixedAcrossRecordedMutations(t *testing.T) {
t.Parallel()
type config struct {
Existing string `yaml:"existing"`
Values map[string]string `yaml:"values"`
}
var callbackErr error
result, err := testLoad[config](Bytes("mutations.yml", []byte("existing: original\nvalues: {original: move}\n")), Options[config]{Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
original, originalFound := resolution.InputInfo(Keys("values", "original"))
_, destinationFound := resolution.InputInfo(Keys("values", "destination"))
existing, existingFound := resolution.InputInfo(Keys("existing"))
Normalize(resolution, Keys("existing"), &value.Existing, "normalized", "normalized existing value")
value.Values["destination"] = value.Values["original"]
delete(value.Values, "original")
resolution.Record(Keys("values"), ChangeNormalization, "moved configured value")
againOriginal, againOriginalFound := resolution.InputInfo(Keys("values", "original"))
againDestination, againDestinationFound := resolution.InputInfo(Keys("values", "destination"))
againExisting, againExistingFound := resolution.InputInfo(Keys("existing"))
if !originalFound || destinationFound || !existingFound || !againOriginalFound || againDestinationFound || !againExistingFound ||
!reflect.DeepEqual(original, againOriginal) || !reflect.DeepEqual(existing, againExisting) || !reflect.DeepEqual(againDestination, FieldInfo{}) {
callbackErr = errors.New("entry view changed during resolution")
}
return callbackErr
}}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
if result.Config.Existing != "normalized" || result.Config.Values["destination"] != "move" {
t.Fatalf("configuration = %#v", result.Config)
}
if _, found := result.Metadata().Lookup(Keys("values", "original")); found {
t.Fatal("final metadata retained a removed path")
}
destination, found := result.Metadata().Lookup(Keys("values", "destination"))
existing, existingFound := result.Metadata().Lookup(Keys("existing"))
if !found || destination.Origin.Layer != LayerResolution || !existingFound || existing.Origin.Layer != LayerResolution {
t.Fatalf("final metadata = destination %#v existing %#v", destination, existing)
}
}
func TestResolutionInputQueriesRemainIndependentAcrossLoads(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
const loads = 16
failures := make(chan error, loads)
var group sync.WaitGroup
for index := 0; index < loads; index++ {
group.Add(1)
go func() {
defer group.Done()
_, err := testLoad[config](Bytes("parallel.yml", []byte("value: stable\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
info, found := resolution.InputInfo(Keys("value"))
source, sourceFound := resolution.Source(info.Origin.Locations[0].SourceID)
if !found || !sourceFound || source.ID != "source-1" {
return errors.New("independent entry lookup failed")
}
info.Origin.Locations[0].SourceID = "mutated"
source.Label = "mutated"
return nil
}}})
if err != nil {
failures <- err
}
}()
}
group.Wait()
close(failures)
for err := range failures {
t.Fatal(err)
}
}
func assertHistoryLayersAndPaths(t *testing.T, history []Change, layers []Layer, paths []string) {
t.Helper()
if len(history) != len(layers) || len(history) != len(paths) {
t.Fatalf("history = %#v", history)
}
for index, change := range history {
if change.Origin.Layer != layers[index] || change.Path.String() != paths[index] {
t.Fatalf("history[%d] = %#v", index, change)
}
}
}

286
internal/engine/records.go Normal file
View File

@@ -0,0 +1,286 @@
package engine
// Layer identifies the precedence layer responsible for a configuration value.
type Layer string
const (
LayerDefault Layer = "default"
LayerBase Layer = "base"
LayerProfile Layer = "profile"
LayerCLI Layer = "cli"
LayerResolution Layer = "resolution"
)
// ChangeKind identifies a recorded operation. The zero value is
// used for input and default origins.
type ChangeKind string
const (
// ChangeDecoding records the built-in transition to canonical typed values.
// It is inspection metadata, not an operation accepted by Resolution.Record.
ChangeDecoding ChangeKind = "decoding"
ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization"
)
// Severity classifies a diagnostic.
type Severity string
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
)
// Code is a stable, machine-readable diagnostic classification.
type Code string
const (
CodeSourceInvalid Code = "source.invalid"
CodeSourceMissing Code = "source.missing"
CodeSourcePermission Code = "source.permission"
CodeSourceRead Code = "source.read"
CodeSourceNotRegular Code = "source.not_regular"
CodeSourceLimitExceeded Code = "source.limit_exceeded"
CodeSourceReaderUsed Code = "source.reader_used"
CodeYAMLInvalid Code = "yaml.invalid"
CodeYAMLEmpty Code = "yaml.empty"
CodeYAMLMultipleDocuments Code = "yaml.multiple_documents"
CodeYAMLRootType Code = "yaml.root_type"
CodeYAMLDuplicateKey Code = "yaml.duplicate_key"
CodeYAMLTag Code = "yaml.tag"
CodeYAMLAnchor Code = "yaml.anchor"
CodeYAMLAlias Code = "yaml.alias"
CodeYAMLMergeKey Code = "yaml.merge_key"
CodeYAMLScalar Code = "yaml.scalar"
CodeYAMLDepthLimit Code = "yaml.depth_limit"
CodeYAMLNodeLimit Code = "yaml.node_limit"
CodeSchemaRoot Code = "schema.root"
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
CodeSchemaTag Code = "schema.tag"
CodeSchemaTextCodec Code = "schema.text_codec"
CodeSchemaInline Code = "schema.inline"
CodeSchemaCycle Code = "schema.cycle"
CodeSchemaProjectionLimit Code = "schema.projection_limit"
CodeSchemaCodec Code = "schema.codec"
CodeSchemaNonFinite Code = "schema.non_finite"
CodeSchemaDynamicType Code = "schema.dynamic_type"
CodeDecodeType Code = "decode.type"
CodeDecodeNull Code = "decode.null"
CodeDecodeRange Code = "decode.range"
CodeDecodeDuration Code = "decode.duration"
CodeDecodeText Code = "decode.text"
CodeDecodeUnknownField Code = "decode.unknown_field"
CodeDecodeArrayLength Code = "decode.array_length"
CodeOptionsInvalid Code = "options.invalid"
CodeOptionsUnavailable Code = "options.unavailable"
CodeImportDisabled Code = "import.disabled"
CodeImportInvalid Code = "import.invalid"
CodeImportMissing Code = "import.missing"
CodeImportPermission Code = "import.permission"
CodeImportRead Code = "import.read"
CodeImportOutsideRoot Code = "import.outside_root"
CodeImportCycle Code = "import.cycle"
CodeImportRepeated Code = "import.repeated"
CodeImportLimitExceeded Code = "import.limit_exceeded"
CodeImportVersion Code = "import.version"
CodeProfileInvalid Code = "profile.invalid"
CodeProfileMissing Code = "profile.missing"
CodeProfileOutsideRoot Code = "profile.outside_root"
CodeProfileRepeated Code = "profile.repeated"
CodeProfileDirective Code = "profile.directive"
CodeProfileVersion Code = "profile.version"
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
CodeComposeInvalid Code = "compose.invalid"
CodeComposeConflict Code = "compose.conflict"
CodeVersionType Code = "version.type"
CodeVersionError Code = "version.error"
CodeVersionWarning Code = "version.warning"
CodeVersionHookFailed Code = "version.hook_failed"
CodeResolveError Code = "resolve.error"
CodeResolveWarning Code = "resolve.warning"
CodeResolveHookFailed Code = "resolve.hook_failed"
CodeResolveInvalidPath Code = "resolve.invalid_path"
CodeResolveInvalidKind Code = "resolve.invalid_kind"
CodeResolveInvalidRecord Code = "resolve.invalid_record"
CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value"
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
CodeValidateError Code = "validate.error"
CodeValidateWarning Code = "validate.warning"
CodeValidateInvalidPath Code = "validate.invalid_path"
CodeValidateMutation Code = "validate.mutation"
CodeSecretsInvalidPath Code = "secrets.invalid_path"
CodeSecretsMissing Code = "secrets.missing"
CodeSecretsPermission Code = "secrets.permission"
CodeSecretsRead Code = "secrets.read"
CodeSecretsNotRegular Code = "secrets.not_regular"
CodeSecretsInvalidName Code = "secrets.invalid_name"
CodeSecretsLimitExceeded Code = "secrets.limit_exceeded"
CodeSecretsInvalidValue Code = "secrets.invalid_value"
CodeSecretsApplyFailed Code = "secrets.apply_failed"
CodeSecretRequired Code = "secret.required"
CodeSecretInvalidName Code = "secret.invalid_name"
CodeSecretUnset Code = "secret.unset"
CodeSecretEmpty Code = "secret.empty"
CodeOverrideInvalidPath Code = "override.invalid_path"
CodeOverrideInvalidLabel Code = "override.invalid_label"
CodeOverrideInvalidValue Code = "override.invalid_value"
CodeOverrideDuplicate Code = "override.duplicate"
CodeOverrideOverlap Code = "override.overlap"
CodeOverrideDirective Code = "override.directive"
CodeOverrideVersion Code = "override.version"
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
)
// Location identifies a position in a configuration source. Zero line and column
// values mean that coordinates are unavailable or synthetic.
type Location struct {
SourceID string
Line int
Column int
}
// SourceInfo describes a source discovered during a load. Path is a canonical
// local path when one exists. ImportedFrom identifies the declaration that
// included this source.
type SourceInfo struct {
ID string
Label string
Path string
Layer Layer
ImportedFrom *Location
}
// Diagnostic is a safe structured problem report. Message and source labels must
// never contain configuration values or other secrets.
type Diagnostic struct {
Code Code
Severity Severity
Path Path
Message string
Primary *Location
Related []Location
}
// Origin describes where an effective definition came from. Multiple locations
// represent a composite origin; resolution origins are synthetic and identify their
// operation instead of a file location.
type Origin struct {
Layer Layer
Locations []Location
Operation ChangeKind
}
// FieldInfo describes an effective configuration path.
type FieldInfo struct {
Explicit bool
Null bool
Origin Origin
}
// Change describes a definition or application-resolution operation. Path can name
// an ancestor operation affecting the queried descendant. Historical values are
// intentionally not exposed by this record.
type Change struct {
Path Path
Origin Origin
Previous []Origin
Reasons []string
Dependencies []Path
}
func cloneLocation(location *Location) *Location {
if location == nil {
return nil
}
cloned := *location
return &cloned
}
func cloneLocations(locations []Location) []Location {
if locations == nil {
return nil
}
return append([]Location(nil), locations...)
}
func cloneSourceInfo(source SourceInfo) SourceInfo {
source.ImportedFrom = cloneLocation(source.ImportedFrom)
return source
}
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
if sources == nil {
return nil
}
cloned := make([]SourceInfo, len(sources))
for i, source := range sources {
cloned[i] = cloneSourceInfo(source)
}
return cloned
}
func cloneOrigin(origin Origin) Origin {
origin.Locations = cloneLocations(origin.Locations)
return origin
}
func cloneFieldInfo(info FieldInfo) FieldInfo {
info.Origin = cloneOrigin(info.Origin)
return info
}
func cloneChange(change Change) Change {
change.Path = change.Path.clone()
change.Origin = cloneOrigin(change.Origin)
if change.Previous != nil {
previous := make([]Origin, len(change.Previous))
for i, origin := range change.Previous {
previous[i] = cloneOrigin(origin)
}
change.Previous = previous
}
change.Reasons = append([]string(nil), change.Reasons...)
if change.Dependencies != nil {
dependencies := make([]Path, len(change.Dependencies))
for i, path := range change.Dependencies {
dependencies[i] = path.clone()
}
change.Dependencies = dependencies
}
return change
}
func cloneDiagnostic(diagnostic Diagnostic) Diagnostic {
diagnostic.Path = diagnostic.Path.clone()
diagnostic.Primary = cloneLocation(diagnostic.Primary)
diagnostic.Related = cloneLocations(diagnostic.Related)
return diagnostic
}
func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic {
if diagnostics == nil {
return nil
}
cloned := make([]Diagnostic, len(diagnostics))
for i, diagnostic := range diagnostics {
cloned[i] = cloneDiagnostic(diagnostic)
}
return cloned
}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -14,7 +14,7 @@ var errEffectiveUnavailable = errors.New("configkit: effective configuration is
// WriteEffective writes deterministic redacted YAML from the frozen resolved // WriteEffective writes deterministic redacted YAML from the frozen resolved
// snapshot. The output is intended for inspection and is not promised reloadable. // snapshot. The output is intended for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output. // Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error { func (r *Inspection) WriteEffective(output io.Writer) error {
if r == nil || r.final == nil || output == nil { if r == nil || r.final == nil || output == nil {
return errEffectiveUnavailable return errEffectiveUnavailable
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"
@@ -85,7 +85,7 @@ sequence: [second, first]
return nil return nil
}, },
} }
result, err := Load[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"}) result, err := testLoad[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -139,7 +139,7 @@ sequence: [second, first]
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) { if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err) t.Fatalf("short writer error = %v", err)
} }
var nilResult *Result[renderConfig] var nilResult *testResult[renderConfig]
if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) { if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
t.Fatalf("nil result error = %v", err) t.Fatalf("nil result error = %v", err)
} }
@@ -160,7 +160,7 @@ func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" } test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" }
result, err := Load[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition}) result, err := testLoad[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err) t.Fatalf("result, error = %#v, %v", result, err)
} }
@@ -205,7 +205,7 @@ func TestDynamicConcreteTagsRemainSecretThroughHistory(t *testing.T) {
Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path) Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path)
return nil return nil
} }
result, err := Load(NoSource(), Options[cfg]{Definition: definition}) result, err := testLoad(NoSource(), Options[cfg]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -234,7 +234,7 @@ func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T
type cfg struct { type cfg struct {
Payload any `yaml:"payload"` Payload any `yaml:"payload"`
} }
_, err := Load(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) { _, err := testLoad(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)} c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)}
}}}) }}})
var failure *LoadError var failure *LoadError

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"reflect" "reflect"
@@ -15,6 +15,7 @@ type resolutionState struct {
schema *schemaDescriptor schema *schemaDescriptor
presence map[string]inputPresence presence map[string]inputPresence
document *document.Node document *document.Node
input resolutionInput
records map[string]*resolutionRecord records map[string]*resolutionRecord
diagnostics []Diagnostic diagnostics []Diagnostic
} }
@@ -50,6 +51,27 @@ func (r *Resolution) IsExplicit(path Path) bool {
return explicit return explicit
} }
// InputInfo returns detached entry-state metadata for path. The entry state is
// the decoded configuration, including retained static defaults, immediately
// before resolution starts. It is fixed while resolution records mutate the
// working configuration; unknown or shape-mismatched paths return false.
//
// InputInfo is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) {
return r.activeState().input.lookup(path)
}
// Source returns a detached record for a source acquired by this load. Empty or
// unknown IDs return false and do not perform input, filesystem, or credential
// access. A source path is present only when that acquired source has one.
//
// Source is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) Source(id string) (SourceInfo, bool) {
return r.activeState().input.source(id)
}
// Record declares the intent for a resolver mutation. A container path covers // Record declares the intent for a resolver mutation. A container path covers
// mutations to its descendants. // mutations to its descendants.
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) { func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
@@ -221,9 +243,10 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string
state.diagnostics = append(state.diagnostics, diagnostic) 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 { func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult {
state := &resolutionState{ state := &resolutionState{
active: true, schema: schema, presence: decoded.presence, document: input, active: true, schema: schema, presence: decoded.presence, document: input,
input: entry,
records: make(map[string]*resolutionRecord), records: make(map[string]*resolutionRecord),
} }
context := &Resolution{state: state} context := &Resolution{state: state}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -41,7 +41,7 @@ func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
}, },
} }
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n" input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
result, err := Load(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition}) result, err := testLoad(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -94,7 +94,7 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t
Absent int `yaml:"absent"` Absent int `yaml:"absent"`
} }
five := 5 five := 5
result, err := Load(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} }, Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) { if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
@@ -126,7 +126,7 @@ func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
Value int `yaml:"value"` Value int `yaml:"value"`
Other int `yaml:"other"` Other int `yaml:"other"`
} }
result, err := Load(NoSource(), Options[config]{Definition: Definition[config]{ result, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Value: 5} }, Defaults: func() config { return config{Value: 5} },
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other")) Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
@@ -157,7 +157,7 @@ func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t
type config struct { type config struct {
Items map[string]item `yaml:"items"` Items map[string]item `yaml:"items"`
} }
result, err := Load(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
delete(configuration.Items, "remove") delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries") resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
@@ -188,7 +188,7 @@ func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
type config struct { type config struct {
Values []int `yaml:"values"` Values []int `yaml:"values"`
} }
result, err := Load(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values") Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
return nil return nil
@@ -237,7 +237,7 @@ func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}}) result, err := testLoad(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
assertLoadDiagnostic(t, result, err, test.code) assertLoadDiagnostic(t, result, err, test.code)
}) })
} }
@@ -269,7 +269,7 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}}) result, err := testLoad(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
loadError := assertLoadDiagnostic(t, result, err, test.code) loadError := assertLoadDiagnostic(t, result, err, test.code)
for _, diagnostic := range loadError.Diagnostics() { for _, diagnostic := range loadError.Diagnostics() {
if strings.Contains(diagnostic.Message, "CANARY") { if strings.Contains(diagnostic.Message, "CANARY") {
@@ -280,6 +280,83 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
} }
} }
func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
t.Parallel()
type config struct {
Declared string `yaml:"declared"`
Default string `yaml:"default"`
Optional *string `yaml:"optional"`
}
filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n")
var savedField FieldInfo
var savedSource SourceInfo
result, err := testLoad[config](File(filename), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} {
info, found := resolution.InputInfo(path)
if !found || info.Explicit != resolution.IsExplicit(path) {
return errors.New("entry metadata does not agree with explicitness")
}
}
defaultInfo, _ := resolution.InputInfo(Keys("default"))
optionalInfo, _ := resolution.InputInfo(Keys("optional"))
if defaultInfo.Explicit || defaultInfo.Origin.Layer != LayerDefault || !optionalInfo.Explicit || !optionalInfo.Null {
return errors.New("entry metadata lost default or null state")
}
if _, found := resolution.InputInfo(Keys("missing")); found {
return errors.New("missing entry path was found")
}
declared, _ := resolution.InputInfo(Keys("declared"))
if len(declared.Origin.Locations) != 1 {
return errors.New("declared source location is missing")
}
source, found := resolution.Source(declared.Origin.Locations[0].SourceID)
if !found || source.Path != canonicalTestPath(t, filename) || source.ID != "source-1" {
return errors.New("declared source record is missing")
}
if _, found := resolution.Source(""); found {
return errors.New("empty source identity was found")
}
if _, found := resolution.Source("unknown"); found {
return errors.New("unknown source identity was found")
}
savedField, savedSource = declared, source
return nil
},
}})
if err != nil {
t.Fatal(err)
}
if result.Config.Declared != "file" || result.Config.Default != "retained" || result.Config.Optional != nil {
t.Fatalf("configuration = %#v", result.Config)
}
savedField.Origin.Locations[0].SourceID = "mutated"
savedSource.Label = "mutated"
field, _ := result.Metadata().Lookup(Keys("declared"))
if field.Origin.Locations[0].SourceID != "source-1" || result.Sources()[0].Label == "mutated" {
t.Fatal("saved resolution records alias a completed result")
}
noSource, err := testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
info, found := resolution.InputInfo(Keys("default"))
if !found || info.Explicit || resolution.IsExplicit(Keys("default")) {
return errors.New("defaults-only entry metadata is wrong")
}
if _, found := resolution.Source("source-1"); found {
return errors.New("defaults-only load invented a source")
}
return nil
},
}})
if err != nil || noSource.Config.Default != "retained" {
t.Fatalf("defaults-only load = %#v, %v", noSource, err)
}
}
func TestResolutionContextPanicsAfterCallback(t *testing.T) { func TestResolutionContextPanicsAfterCallback(t *testing.T) {
t.Parallel() t.Parallel()
@@ -287,7 +364,7 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
Value int `yaml:"value"` Value int `yaml:"value"`
} }
var retained *Resolution var retained *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution retained = resolution
return nil return nil
}}}); err != nil { }}}); err != nil {
@@ -301,6 +378,45 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
retained.IsExplicit(Keys("value")) retained.IsExplicit(Keys("value"))
} }
func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
requireClosed := func(t *testing.T, call func()) {
t.Helper()
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
call()
}
requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) })
requireClosed(t, func() { (&Resolution{}).Source("source-1") })
var successful *Resolution
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
successful = resolution
return nil
}}}); err != nil {
t.Fatal(err)
}
requireClosed(t, func() { successful.InputInfo(Keys("value")) })
requireClosed(t, func() { successful.Source("source-1") })
var failed *Resolution
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
failed = resolution
return errors.New("resolver failed")
}}}); err == nil {
t.Fatal("failed resolver succeeded")
}
requireClosed(t, func() { failed.InputInfo(Keys("value")) })
requireClosed(t, func() { failed.Source("source-1") })
}
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) { func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
t.Parallel() t.Parallel()
@@ -308,7 +424,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
Version string `yaml:"version"` Version string `yaml:"version"`
} }
var retained *Validation var retained *Validation
if _, err := Load(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{ if _, err := testLoad(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error { Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation retained = validation
return nil return nil
@@ -324,7 +440,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
retained.Warning(Keys("version"), "too late") retained.Warning(Keys("version"), "too late")
} }
func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError { func assertLoadDiagnostic[T any](t *testing.T, result *testResult[T], err error, code Code) *LoadError {
t.Helper() t.Helper()
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"encoding" "encoding"
@@ -31,6 +31,7 @@ const (
type schemaDescriptor struct { type schemaDescriptor struct {
root *typeDescriptor root *typeDescriptor
types map[reflect.Type]*typeDescriptor types map[reflect.Type]*typeDescriptor
secrets SecretBinding
} }
type typeDescriptor struct { type typeDescriptor struct {
@@ -65,20 +66,39 @@ type schemaIssue struct {
message string message string
} }
// SecretState is the private operation state for one exact-type secret value.
// It is supplied by the facade and never enters inspection records.
type SecretState struct {
Name string
Value string
Specified bool
Resolved bool
}
// SecretBinding adapts the facade-owned exact secret type for one load.
// Bindings are operation data: callers must supply the type and conversions
// together, and a binding never becomes package-global state.
type SecretBinding struct {
Type reflect.Type
Read func(reflect.Value) SecretState
Make func(SecretState) reflect.Value
}
var ( var (
durationType = reflect.TypeFor[time.Duration]() durationType = reflect.TypeFor[time.Duration]()
secretType = reflect.TypeFor[Secret]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
) )
func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) { func buildSchema(root reflect.Type, secrets SecretBinding) (*schemaDescriptor, *schemaIssue) {
secrets.validate()
if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) { if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"} return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"}
} }
builder := schemaBuilder{ builder := schemaBuilder{
cache: make(map[reflect.Type]*typeDescriptor), cache: make(map[reflect.Type]*typeDescriptor),
building: make(map[reflect.Type]bool), building: make(map[reflect.Type]bool),
secrets: secrets,
} }
descriptor, issue := builder.describe(root, Path{}) descriptor, issue := builder.describe(root, Path{})
if issue != nil { if issue != nil {
@@ -87,12 +107,13 @@ func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap { if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"} return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"}
} }
return &schemaDescriptor{root: descriptor, types: builder.cache}, nil return &schemaDescriptor{root: descriptor, types: builder.cache, secrets: secrets}, nil
} }
type schemaBuilder struct { type schemaBuilder struct {
cache map[reflect.Type]*typeDescriptor cache map[reflect.Type]*typeDescriptor
building map[reflect.Type]bool building map[reflect.Type]bool
secrets SecretBinding
} }
func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) { func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) {
@@ -122,7 +143,7 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
descriptor.kind = descriptorDuration descriptor.kind = descriptorDuration
return descriptor, nil return descriptor, nil
} }
if current == secretType { if b.secrets.Type != nil && current == b.secrets.Type {
descriptor.kind = descriptorSecret descriptor.kind = descriptorSecret
return descriptor, nil return descriptor, nil
} }
@@ -189,6 +210,15 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
return descriptor, nil return descriptor, nil
} }
func (binding SecretBinding) validate() {
if binding.Type == nil {
return
}
if binding.Read == nil || binding.Make == nil {
panic("configkit engine: incomplete secret binding")
}
}
func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue { func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue {
for i := 0; i < current.NumField(); i++ { for i := 0; i < current.NumField(); i++ {
field := current.Field(i) field := current.Field(i)

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -68,9 +68,9 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
private chan int private chan int
} }
schema, issue := buildSchema(reflect.TypeFor[config]()) schema, issue := testBuildSchema(reflect.TypeFor[config]())
if issue != nil { if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue) t.Fatalf("testBuildSchema() issue = %#v", issue)
} }
root := schema.root root := schema.root
checks := map[string]descriptorKind{ checks := map[string]descriptorKind{
@@ -105,7 +105,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap { if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap {
t.Fatalf("inline map = %#v", root.inlineMap) t.Fatalf("inline map = %#v", root.inlineMap)
} }
if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap { if mapped, mapIssue := testBuildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue) t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue)
} }
if _, exists := root.fieldByName["ignored"]; exists { if _, exists := root.fieldByName["ignored"]; exists {
@@ -116,7 +116,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) { func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) {
t.Parallel() t.Parallel()
first, issue := buildSchema(reflect.TypeFor[recursiveConfig]()) first, issue := testBuildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil { if issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
@@ -128,7 +128,7 @@ func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T
if children.kind != descriptorMap || children.element != first.root { if children.kind != descriptorMap || children.element != first.root {
t.Fatal("recursive map did not refer to the existing descriptor") t.Fatal("recursive map did not refer to the existing descriptor")
} }
second, issue := buildSchema(reflect.TypeFor[recursiveConfig]()) second, issue := testBuildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil { if issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
@@ -207,7 +207,7 @@ func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code { if _, issue := testBuildSchema(test.typeOf); issue == nil || issue.code != test.code {
t.Fatalf("issue = %#v, want %s", issue, test.code) t.Fatalf("issue = %#v, want %s", issue, test.code)
} }
}) })
@@ -221,7 +221,7 @@ func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) {
Value panicText `yaml:"value"` Value panicText `yaml:"value"`
Structural yamlHookStruct `yaml:"structural"` Structural yamlHookStruct `yaml:"structural"`
} }
if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil { if _, issue := testBuildSchema(reflect.TypeFor[config]()); issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
} }
@@ -261,7 +261,7 @@ func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) {
Dynamic any `yaml:"dynamic"` Dynamic any `yaml:"dynamic"`
Extras map[string]string `yaml:",inline"` Extras map[string]string `yaml:",inline"`
} }
schema, issue := buildSchema(reflect.TypeFor[config]()) schema, issue := testBuildSchema(reflect.TypeFor[config]())
if issue != nil { if issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
@@ -310,7 +310,7 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
} }
for _, nonNil := range []bool{false, true} { for _, nonNil := range []bool{false, true} {
called := false called := false
_, err := Load(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { _, err := testLoad(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg {
called = true called = true
if nonNil { if nonNil {
m := map[string]int{"value": 1} m := map[string]int{"value": 1}
@@ -330,13 +330,13 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
type recursive struct { type recursive struct {
Extra pointer `yaml:",inline"` Extra pointer `yaml:",inline"`
} }
if _, err := Load[recursive](NoSource()); err == nil { if _, err := testLoad[recursive](NoSource()); err == nil {
t.Fatal("recursive inline pointer was accepted") t.Fatal("recursive inline pointer was accepted")
} }
type double struct { type double struct {
Extra **map[string]int `yaml:",inline"` Extra **map[string]int `yaml:",inline"`
} }
if _, err := Load[double](NoSource()); err == nil { if _, err := testLoad[double](NoSource()); err == nil {
t.Fatal("double inline map pointer was accepted") t.Fatal("double inline map pointer was accepted")
} }
} }

153
internal/engine/secret.go Normal file
View File

@@ -0,0 +1,153 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
type secretDependency struct {
target Path
dependency Path
}
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
paths := make(map[string]Path)
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
for pointer := range finalPaths {
addSecretPath(paths, pointer)
}
for _, application := range applications {
applicationPaths := make(map[string]*document.Node)
indexDocument(application.value, application.path, applicationPaths)
for pointer := range applicationPaths {
addSecretPath(paths, pointer)
}
}
var dependencies []secretDependency
for pointer, history := range metadata.history {
addSecretPath(paths, pointer)
for _, change := range history {
paths[change.Path.String()] = change.Path.clone()
for _, dependency := range change.Dependencies {
paths[dependency.String()] = dependency.clone()
dependencies = append(dependencies, secretDependency{
target: change.Path.clone(), dependency: dependency.clone(),
})
}
}
}
secret := make(map[string]bool)
for _, tree := range append(projections, final) {
projected := make(map[string]*document.Node)
indexDocument(tree, Path{}, projected)
for pointer, node := range projected {
addSecretPath(paths, pointer)
if node.Sensitive {
secret[pointer] = true
}
}
}
for pointer, path := range paths {
if root, ok := schemaSecretRoot(schema, path); ok {
secret[root.String()] = true
}
if predicate != nil && predicate(path.clone()) {
secret[pointer] = true
}
}
for {
changed := false
for _, dependency := range dependencies {
if secretIntersectsPath(paths, secret, dependency.dependency) && !secret[dependency.target.String()] {
secret[dependency.target.String()] = true
changed = true
}
}
if !changed {
return secret
}
}
}
func addSecretPath(paths map[string]Path, pointer string) {
if path, err := ParsePath(pointer); err == nil {
paths[pointer] = path
}
}
func secretIntersectsPath(paths map[string]Path, secret map[string]bool, candidate Path) bool {
for pointer := range secret {
classified, ok := paths[pointer]
if !ok {
continue
}
if pathContains(classified, candidate) || pathContains(candidate, classified) {
return true
}
}
return false
}
func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
if schema == nil || schema.root == nil || !path.valid() {
return Path{}, false
}
current := schema.root
walked := Path{}
for _, segment := range path.segments {
walked.segments = append(walked.segments, segment)
for current.kind == descriptorPointer {
current = current.element
}
switch current.kind {
case descriptorStruct:
if segment.kind == pathSegmentIndex {
return Path{}, false
}
position, ok := current.fieldByName[segment.key]
var field *fieldDescriptor
if ok {
field = &current.fields[position]
} else {
field = current.inlineMap
}
if field == nil {
return Path{}, false
}
if field.secret {
return walked.clone(), true
}
if field.inlineMap {
current = field.descriptor.element
} else {
current = field.descriptor
}
case descriptorMap:
if segment.kind == pathSegmentIndex {
return Path{}, false
}
current = current.element
case descriptorSlice, descriptorArray:
if _, ok := schemaIndex(segment); !ok {
return Path{}, false
}
current = current.element
case descriptorDynamic:
return Path{}, false
default:
return Path{}, false
}
}
return Path{}, false
}
// Dynamic type replacement can change classification even when projected text
// is equal. Validators must preserve this inspection metadata as well as values.
func projectedSensitivity(root *document.Node) map[string]Path {
result := make(map[string]Path)
walkDocument(root, Path{}, func(path Path, node *document.Node) {
if node.Sensitive {
result[path.String()] = path.clone()
}
})
return result
}

View File

@@ -0,0 +1,157 @@
package engine
import (
"encoding/json"
"fmt"
"io"
"reflect"
"strconv"
"testing"
)
// Secret is a test-local facade secret. Production engine code must not depend
// on this type; each test load supplies the binding below explicitly.
type Secret struct {
name string
value string
specified bool
resolved bool
}
func SecretEnv(name string) Secret {
return Secret{name: name, specified: true}
}
func (s Secret) EnvName() string {
return s.name
}
func (s Secret) Value() string {
if !s.resolved {
return ""
}
return s.value
}
func (s Secret) IsResolved() bool {
return s.resolved
}
func (s Secret) Format(state fmt.State, _ rune) {
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
}
func (s Secret) MarshalJSON() ([]byte, error) {
return json.Marshal(s.name)
}
func testSecretBinding() SecretBinding {
return SecretBinding{
Type: reflect.TypeFor[Secret](),
Read: func(value reflect.Value) SecretState {
secret := value.Interface().(Secret)
return SecretState{Name: secret.name, Value: secret.value, Specified: secret.specified, Resolved: secret.resolved}
},
Make: func(state SecretState) reflect.Value {
return reflect.ValueOf(Secret{name: state.Name, value: state.Value, specified: state.Specified, resolved: state.Resolved})
},
}
}
func testLoad[T any](source Source, supplied ...Options[T]) (*testResult[T], error) {
loaded, err := Load(source, testSecretBinding(), supplied...)
return testLoaded(loaded), err
}
func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*testResult[T], error) {
loaded, err := loadWithSecretLookup(source, testSecretBinding(), lookup, supplied...)
return testLoaded(loaded), err
}
type testResult[T any] struct {
Config T
*Inspection
}
func testLoaded[T any](loaded *Loaded[T]) *testResult[T] {
if loaded == nil {
return nil
}
return &testResult[T]{Config: loaded.Config, Inspection: loaded.Inspection}
}
func (r *testResult[T]) Metadata() Metadata {
if r == nil {
return Metadata{}
}
return r.Inspection.Metadata()
}
func (r *testResult[T]) Sources() []SourceInfo {
if r == nil {
return nil
}
return r.Inspection.Sources()
}
func (r *testResult[T]) Diagnostics() []Diagnostic {
if r == nil {
return nil
}
return r.Inspection.Diagnostics()
}
func (r *testResult[T]) Explain(path Path) (Explanation, bool) {
if r == nil {
return Explanation{}, false
}
return r.Inspection.Explain(path)
}
func (r *testResult[T]) WriteEffective(output io.Writer) error {
if r == nil {
return errEffectiveUnavailable
}
return r.Inspection.WriteEffective(output)
}
func (r *testResult[T]) WriteExplanation(output io.Writer, path Path) error {
if r == nil {
return errExplanationUnknownPath
}
return r.Inspection.WriteExplanation(output, path)
}
func testBuildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
return buildSchema(root, testSecretBinding())
}
func TestSecretBindingsRemainExactAndIndependent(t *testing.T) {
t.Parallel()
type otherSecret struct {
name string
}
other := SecretBinding{
Type: reflect.TypeFor[otherSecret](),
Read: func(value reflect.Value) SecretState {
return SecretState{Name: value.Interface().(otherSecret).name}
},
Make: func(state SecretState) reflect.Value {
return reflect.ValueOf(otherSecret{name: state.Name})
},
}
type config struct {
First Secret `yaml:"first"`
Second otherSecret `yaml:"second"`
}
firstSchema, firstIssue := buildSchema(reflect.TypeFor[config](), testSecretBinding())
if firstIssue != nil {
t.Fatal(firstIssue)
}
secondSchema, secondIssue := buildSchema(reflect.TypeFor[config](), other)
if secondIssue != nil {
t.Fatal(secondIssue)
}
firstField := firstSchema.root.fields[firstSchema.root.fieldByName["first"]].descriptor
firstOther := firstSchema.root.fields[firstSchema.root.fieldByName["second"]].descriptor
secondField := secondSchema.root.fields[secondSchema.root.fieldByName["first"]].descriptor
secondOther := secondSchema.root.fields[secondSchema.root.fieldByName["second"]].descriptor
if firstField.kind != descriptorSecret || firstOther.kind == descriptorSecret || secondField.kind == descriptorSecret || secondOther.kind != descriptorSecret {
t.Fatalf("secret descriptor kinds = %v, %v, %v, %v", firstField.kind, firstOther.kind, secondField.kind, secondOther.kind)
}
}

View File

@@ -1,61 +1,13 @@
package configkit package engine
import ( import (
"cmp" "cmp"
"encoding/json"
"fmt"
"io"
"reflect" "reflect"
"slices" "slices"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
// Secret is an opaque reference to an environment variable. Its name is public
// configuration; its resolved value is available only through Value.
type Secret struct {
name string
value string
specified bool
resolved bool
}
// SecretEnv constructs an unresolved environment-backed secret reference.
// Invalid names are reported if the reference survives a configuration load.
func SecretEnv(name string) Secret {
return Secret{name: name, specified: true}
}
// EnvName returns the public environment-variable name.
func (s Secret) EnvName() string {
return s.name
}
// Value returns the resolved credential, or an empty string when unresolved.
func (s Secret) Value() string {
if !s.resolved {
return ""
}
return s.value
}
// IsResolved reports whether this reference resolved to a nonempty value during
// its configuration load.
func (s Secret) IsResolved() bool {
return s.resolved
}
// Format renders only the public reference name for every formatting verb.
func (s Secret) Format(state fmt.State, _ rune) {
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
}
// MarshalJSON emits only the public environment-variable name.
func (s Secret) MarshalJSON() ([]byte, error) {
return json.Marshal(s.name)
}
type secretLookup func(string) (string, bool) type secretLookup func(string) (string, bool)
type secretLookupResult struct { type secretLookupResult struct {
@@ -80,28 +32,28 @@ func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input
if lookup == nil { if lookup == nil {
lookup = func(string) (string, bool) { return "", false } lookup = func(string) (string, bool) { return "", false }
} }
resolveSecretValue(root, schema.root, Path{}, false, input, lookup, &result) resolveSecretValue(root, schema.root, schema.secrets, Path{}, false, input, lookup, &result)
result.diagnostics = canonicalDiagnostics(result.diagnostics) result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result return result
} }
func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) { func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) {
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
if value.IsNil() { if value.IsNil() {
return return
} }
resolveSecretValue(value.Elem(), descriptor.element, path, descriptor.element.kind == descriptorSecret, input, lookup, result) resolveSecretValue(value.Elem(), descriptor.element, secrets, path, descriptor.element.kind == descriptorSecret, input, lookup, result)
return return
} }
switch descriptor.kind { switch descriptor.kind {
case descriptorSecret: case descriptorSecret:
reference := value.Interface().(Secret) reference := secrets.Read(value)
name := reference.EnvName() name := reference.Name
if name == "" { if name == "" {
code := CodeSecretRequired code := CodeSecretRequired
message := "required secret reference is absent" message := "required secret reference is absent"
if optional || reference.specified { if optional || reference.Specified {
code = CodeSecretInvalidName code = CodeSecretInvalidName
message = "secret environment name is empty" message = "secret environment name is empty"
} }
@@ -125,7 +77,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input)) result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input))
return return
} }
value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true})) value.Set(secrets.Make(SecretState{Name: name, Value: found.value, Specified: true, Resolved: true}))
result.references[path.String()] = name result.references[path.String()] = name
case descriptorStruct: case descriptorStruct:
for _, member := range descriptor.members { for _, member := range descriptor.members {
@@ -133,7 +85,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
if !member.inline { if !member.inline {
memberPath = path.Key(member.name) memberPath = path.Key(member.name)
} }
resolveSecretValue(value.Field(member.index), member.descriptor, memberPath, false, input, lookup, result) resolveSecretValue(value.Field(member.index), member.descriptor, secrets, memberPath, false, input, lookup, result)
} }
case descriptorMap: case descriptorMap:
if value.IsNil() { if value.IsNil() {
@@ -144,7 +96,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
for _, key := range keys { for _, key := range keys {
entry := reflect.New(value.Type().Elem()).Elem() entry := reflect.New(value.Type().Elem()).Elem()
entry.Set(value.MapIndex(key)) entry.Set(value.MapIndex(key))
resolveSecretValue(entry, descriptor.element, path.Key(key.String()), false, input, lookup, result) resolveSecretValue(entry, descriptor.element, secrets, path.Key(key.String()), false, input, lookup, result)
value.SetMapIndex(key, entry) value.SetMapIndex(key, entry)
} }
case descriptorSlice, descriptorArray: case descriptorSlice, descriptorArray:
@@ -152,7 +104,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
return return
} }
for index := 0; index < value.Len(); index++ { for index := 0; index < value.Len(); index++ {
resolveSecretValue(value.Index(index), descriptor.element, path.Index(index), false, input, lookup, result) resolveSecretValue(value.Index(index), descriptor.element, secrets, path.Index(index), false, input, lookup, result)
} }
} }
} }
@@ -165,25 +117,28 @@ func secretDiagnostic(code Code, path Path, message string, input *document.Node
return diagnostic return diagnostic
} }
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path { func changedSecretStates(root reflect.Value, schema *schemaDescriptor, lookups map[string]secretLookupResult) []Path {
var changed []Path var changed []Path
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed) if schema == nil || schema.root == nil {
return nil
}
collectChangedSecretStates(root, schema.root, schema.secrets, Path{}, lookups, &changed)
slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) }) slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) })
return changed return changed
} }
func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, path Path, lookups map[string]secretLookupResult, changed *[]Path) { func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, lookups map[string]secretLookupResult, changed *[]Path) {
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
if !value.IsNil() { if !value.IsNil() {
collectChangedSecretStates(value.Elem(), descriptor.element, path, lookups, changed) collectChangedSecretStates(value.Elem(), descriptor.element, secrets, path, lookups, changed)
} }
return return
} }
switch descriptor.kind { switch descriptor.kind {
case descriptorSecret: case descriptorSecret:
reference := value.Interface().(Secret) reference := secrets.Read(value)
expected, ok := lookups[reference.EnvName()] expected, ok := lookups[reference.Name]
if ok && (!expected.present || expected.value == "" || !reference.resolved || reference.value != expected.value) { if ok && (!expected.present || expected.value == "" || !reference.Resolved || reference.Value != expected.value) {
*changed = append(*changed, path.clone()) *changed = append(*changed, path.clone())
} }
case descriptorStruct: case descriptorStruct:
@@ -192,7 +147,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
if !member.inline { if !member.inline {
memberPath = path.Key(member.name) memberPath = path.Key(member.name)
} }
collectChangedSecretStates(value.Field(member.index), member.descriptor, memberPath, lookups, changed) collectChangedSecretStates(value.Field(member.index), member.descriptor, secrets, memberPath, lookups, changed)
} }
case descriptorMap: case descriptorMap:
if value.IsNil() { if value.IsNil() {
@@ -201,14 +156,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
keys := value.MapKeys() keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) }) slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
for _, key := range keys { for _, key := range keys {
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed) collectChangedSecretStates(value.MapIndex(key), descriptor.element, secrets, path.Key(key.String()), lookups, changed)
} }
case descriptorSlice, descriptorArray: case descriptorSlice, descriptorArray:
if descriptor.kind == descriptorSlice && value.IsNil() { if descriptor.kind == descriptorSlice && value.IsNil() {
return return
} }
for index := 0; index < value.Len(); index++ { for index := 0; index < value.Len(); index++ {
collectChangedSecretStates(value.Index(index), descriptor.element, path.Index(index), lookups, changed) collectChangedSecretStates(value.Index(index), descriptor.element, secrets, path.Index(index), lookups, changed)
} }
} }
} }

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"encoding/json" "encoding/json"
@@ -18,6 +18,16 @@ type secretReferenceConfig struct {
Sequence []Secret `yaml:"sequence"` Sequence []Secret `yaml:"sequence"`
} }
type resolutionQueryReader struct {
input *strings.Reader
reads int
}
func (reader *resolutionQueryReader) Read(data []byte) (int, error) {
reader.reads++
return reader.input.Read(data)
}
func TestSecretReferencesResolveTypedShapesOncePerName(t *testing.T) { func TestSecretReferencesResolveTypedShapesOncePerName(t *testing.T) {
input := `required: SHARED_SECRET input := `required: SHARED_SECRET
optional: OPTIONAL_SECRET optional: OPTIONAL_SECRET
@@ -41,7 +51,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET]
return value, present return value, present
} }
validationCalled := false validationCalled := false
result, err := loadWithSecretLookup(Bytes("secrets.yml", []byte(input)), lookup, Options[secretReferenceConfig]{ result, err := testLoadWithSecretLookup(Bytes("secrets.yml", []byte(input)), lookup, Options[secretReferenceConfig]{
Definition: Definition[secretReferenceConfig]{Validate: func(configuration *secretReferenceConfig, validation *Validation) { Definition: Definition[secretReferenceConfig]{Validate: func(configuration *secretReferenceConfig, validation *Validation) {
validationCalled = true validationCalled = true
if configuration.Required.Value() != "shared-value" || !configuration.Required.IsResolved() || if configuration.Required.Value() != "shared-value" || !configuration.Required.IsResolved() ||
@@ -52,7 +62,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET]
}}, }},
}) })
if err != nil { if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err) t.Fatalf("testLoadWithSecretLookup() error = %v", err)
} }
if !validationCalled || result.Config.Optional == nil || result.Config.Pointers["nil"] != nil { if !validationCalled || result.Config.Optional == nil || result.Config.Pointers["nil"] != nil {
t.Fatalf("resolved config = %#v", result.Config) t.Fatalf("resolved config = %#v", result.Config)
@@ -84,7 +94,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
if lookup == nil { if lookup == nil {
lookup = func(string) (string, bool) { return "", false } lookup = func(string) (string, bool) { return "", false }
} }
_, err := loadWithSecretLookup(Bytes("secret-errors.yml", []byte(test.input)), lookup, Options[secretReferenceConfig]{}) _, err := testLoadWithSecretLookup(Bytes("secret-errors.yml", []byte(test.input)), lookup, Options[secretReferenceConfig]{})
loadError := requireSecretLoadCode(t, err, test.code) loadError := requireSecretLoadCode(t, err, test.code)
diagnostic := loadError.Diagnostics()[0] diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != "/required" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") { if diagnostic.Path.String() != "/required" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") {
@@ -97,9 +107,9 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
type config struct { type config struct {
Optional *Secret `yaml:"optional"` Optional *Secret `yaml:"optional"`
} }
_, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: ''\n")), func(string) (string, bool) { return "", false }, Options[config]{}) _, err := testLoadWithSecretLookup(Bytes("optional.yml", []byte("optional: ''\n")), func(string) (string, bool) { return "", false }, Options[config]{})
requireSecretLoadCode(t, err, CodeSecretInvalidName) requireSecretLoadCode(t, err, CodeSecretInvalidName)
result, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: null\n")), func(string) (string, bool) { return "", false }, Options[config]{}) result, err := testLoadWithSecretLookup(Bytes("optional.yml", []byte("optional: null\n")), func(string) (string, bool) { return "", false }, Options[config]{})
if err != nil || result.Config.Optional != nil { if err != nil || result.Config.Optional != nil {
t.Fatalf("nil optional result = %#v, %v", result, err) t.Fatalf("nil optional result = %#v, %v", result, err)
} }
@@ -110,7 +120,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
Secrets []Secret `yaml:"secrets"` Secrets []Secret `yaml:"secrets"`
} }
lookups := 0 lookups := 0
_, err := loadWithSecretLookup(Bytes("missing-twice.yml", []byte("secrets: [MISSING_SHARED, MISSING_SHARED]\n")), func(string) (string, bool) { _, err := testLoadWithSecretLookup(Bytes("missing-twice.yml", []byte("secrets: [MISSING_SHARED, MISSING_SHARED]\n")), func(string) (string, bool) {
lookups++ lookups++
return "", false return "", false
}, Options[config]{}) }, Options[config]{})
@@ -128,7 +138,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
} }
lookedUp := []string{} lookedUp := []string{}
validated := "" validated := ""
result, err := loadWithSecretLookup(Bytes("resolved-secret.yml", []byte("name: SELECTED_SECRET\nsecret: OLD_SECRET\n")), func(name string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("resolved-secret.yml", []byte("name: SELECTED_SECRET\nsecret: OLD_SECRET\n")), func(name string) (string, bool) {
lookedUp = append(lookedUp, name) lookedUp = append(lookedUp, name)
return "resolved-value", true return "resolved-value", true
}, Options[config]{Definition: Definition[config]{ }, Options[config]{Definition: Definition[config]{
@@ -141,7 +151,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
}, },
}}) }})
if err != nil { if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err) t.Fatalf("testLoadWithSecretLookup() error = %v", err)
} }
if !reflect.DeepEqual(lookedUp, []string{"SELECTED_SECRET"}) || validated != "resolved-value" || result.Config.Secret.EnvName() != "SELECTED_SECRET" { if !reflect.DeepEqual(lookedUp, []string{"SELECTED_SECRET"}) || validated != "resolved-value" || result.Config.Secret.EnvName() != "SELECTED_SECRET" {
t.Fatalf("lookups = %#v, validated = %q, config = %#v", lookedUp, validated, result.Config) t.Fatalf("lookups = %#v, validated = %q, config = %#v", lookedUp, validated, result.Config)
@@ -152,11 +162,55 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
} }
} }
func TestResolutionInputQueriesDoNotReadOrResolveSecrets(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
Secret Secret `yaml:"secret"`
}
reader := &resolutionQueryReader{input: strings.NewReader("value: source\nsecret: QUERY_SECRET\n")}
lookups := 0
var callbackErr error
result, err := testLoadWithSecretLookup(Reader("query.yml", reader), func(name string) (string, bool) {
lookups++
if name != "QUERY_SECRET" {
t.Fatalf("secret lookup name = %q", name)
}
return "query-credential", true
}, Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
reads := reader.reads
value, valueFound := resolution.InputInfo(Keys("value"))
secret, secretFound := resolution.InputInfo(Keys("secret"))
if !valueFound || !secretFound || len(value.Origin.Locations) != 1 || len(secret.Origin.Locations) != 1 {
callbackErr = errors.New("entry metadata is incomplete")
return callbackErr
}
if source, found := resolution.Source(value.Origin.Locations[0].SourceID); !found || source.Path != "" {
callbackErr = errors.New("entry source is incomplete")
return callbackErr
}
if reader.reads != reads || lookups != 0 {
callbackErr = errors.New("entry query performed input or credential access")
}
return callbackErr
}}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
if lookups != 1 || result.Config.Secret.Value() != "query-credential" {
t.Fatalf("secret resolution = lookups %d config %#v", lookups, result.Config)
}
}
func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) { func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
_, err := loadWithSecretLookup(Bytes("validator.yml", []byte("secret: VALIDATOR_SECRET\n")), func(string) (string, bool) { _, err := testLoadWithSecretLookup(Bytes("validator.yml", []byte("secret: VALIDATOR_SECRET\n")), func(string) (string, bool) {
return "CREDENTIAL-CANARY", true return "CREDENTIAL-CANARY", true
}, Options[config]{Definition: Definition[config]{Validate: func(configuration *config, validation *Validation) { }, Options[config]{Definition: Definition[config]{Validate: func(configuration *config, validation *Validation) {
configuration.Secret = SecretEnv(configuration.Secret.EnvName()) configuration.Secret = SecretEnv(configuration.Secret.EnvName())
@@ -171,13 +225,13 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
first, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "first-value", true }, Options[config]{ first, err := testLoadWithSecretLookup(NoSource(), func(string) (string, bool) { return "first-value", true }, Options[config]{
Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("DEFAULT_SECRET")} }}, Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("DEFAULT_SECRET")} }},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
second, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "second-value", true }, Options[config]{ second, err := testLoadWithSecretLookup(NoSource(), func(string) (string, bool) { return "second-value", true }, Options[config]{
Definition: Definition[config]{Defaults: func() config { return first.Config }}, Definition: Definition[config]{Defaults: func() config { return first.Config }},
}) })
if err != nil { if err != nil {
@@ -189,14 +243,14 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) {
t.Setenv("CONFIGKIT_RELOOKUP_SECRET", "one") t.Setenv("CONFIGKIT_RELOOKUP_SECRET", "one")
options := Options[config]{Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("CONFIGKIT_RELOOKUP_SECRET")} }}} options := Options[config]{Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("CONFIGKIT_RELOOKUP_SECRET")} }}}
realFirst, err := Load(NoSource(), options) realFirst, err := testLoad(NoSource(), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.Setenv("CONFIGKIT_RELOOKUP_SECRET", "two"); err != nil { if err := os.Setenv("CONFIGKIT_RELOOKUP_SECRET", "two"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
realSecond, err := Load(NoSource(), options) realSecond, err := testLoad(NoSource(), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -222,7 +276,7 @@ func TestSecretSafeFormattingJSONProjectionAndHistory(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
result, err := loadWithSecretLookup(Bytes("safe-secret.yml", []byte("secret: SAFE_SECRET_NAME\n")), func(string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("safe-secret.yml", []byte("secret: SAFE_SECRET_NAME\n")), func(string) (string, bool) {
return credential, true return credential, true
}, Options[config]{}) }, Options[config]{})
if err != nil { if err != nil {
@@ -263,7 +317,7 @@ func TestBootstrapExistingEmptyWinsAndReferenceFails(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
_, err = Load[config](Bytes("blocked.yml", []byte("secret: "+name+"\n"))) _, err = testLoad[config](Bytes("blocked.yml", []byte("secret: "+name+"\n")))
requireSecretLoadCode(t, err, CodeSecretEmpty) requireSecretLoadCode(t, err, CodeSecretEmpty)
if value, present := os.LookupEnv(name); !present || value != "" { if value, present := os.LookupEnv(name); !present || value != "" {
t.Fatalf("environment changed to %q, %v", value, present) t.Fatalf("environment changed to %q, %v", value, present)
@@ -285,7 +339,7 @@ func TestDynamicValuesDoNotEstablishSecretReferences(t *testing.T) {
} }
} }
lookups := 0 lookups := 0
result, err := loadWithSecretLookup(Bytes("dynamic.yml", []byte("dynamic: ORDINARY_STRING\n")), func(string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("dynamic.yml", []byte("dynamic: ORDINARY_STRING\n")), func(string) (string, bool) {
lookups++ lookups++
return "unexpected", true return "unexpected", true
}, Options[config]{}) }, Options[config]{})
@@ -300,9 +354,9 @@ func TestLoadDoesNotMutateSecretEnvironment(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
result, err := Load[config](Bytes("unchanged.yml", []byte("secret: "+name+"\n"))) result, err := testLoad[config](Bytes("unchanged.yml", []byte("secret: "+name+"\n")))
if err != nil || result.Config.Secret.Value() != "original" { if err != nil || result.Config.Secret.Value() != "original" {
t.Fatalf("Load() = %#v, %v", result, err) t.Fatalf("testLoad() = %#v, %v", result, err)
} }
if got := os.Getenv(name); got != "original" { if got := os.Getenv(name); got != "original" {
t.Fatalf("environment = %q", got) t.Fatalf("environment = %q", got)

376
internal/engine/secrets.go Normal file
View File

@@ -0,0 +1,376 @@
package engine
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
)
const (
secretsEntryLimit = 1024
secretFileByteLimit = int64(1 << 20)
secretsByteLimit = int64(8 << 20)
)
// SecretFileStatus describes the outcome for one file considered by
// LoadSecretsDir.
type SecretFileStatus string
const (
SecretFileLoaded SecretFileStatus = "loaded"
SecretFileSkippedExisting SecretFileStatus = "skipped_existing"
SecretFileNotApplied SecretFileStatus = "not_applied"
SecretFileFailed SecretFileStatus = "failed"
)
// SecretFileResult is the safe, value-free bootstrap result for one directory
// entry. ExistingEmpty is meaningful when Status is SecretFileSkippedExisting.
type SecretFileResult struct {
Name string
Path string
Status SecretFileStatus
ExistingEmpty bool
}
// SecretsReport is a detached snapshot of an explicit secrets-directory
// bootstrap. It does not retain file contents or environment values.
type SecretsReport struct {
entries []SecretFileResult
}
// Entries returns a copy of the report's filename-ordered entries.
func (r *SecretsReport) Entries() []SecretFileResult {
if r == nil {
return nil
}
return slices.Clone(r.entries)
}
// WriteTo renders one safe, deterministic line per entry. Names and paths are
// quoted so control characters cannot affect terminal output.
func (r *SecretsReport) WriteTo(output io.Writer) (int64, error) {
if output == nil {
return 0, errors.New("configkit: secrets report output is nil")
}
if r == nil {
return 0, nil
}
var total int64
for _, entry := range r.entries {
line := "status=" + strconv.Quote(string(entry.Status)) +
" name=" + strconv.Quote(entry.Name) +
" path=" + strconv.Quote(entry.Path) +
" existing_empty=" + strconv.FormatBool(entry.ExistingEmpty) + "\n"
written, err := io.WriteString(output, line)
total += int64(written)
if err != nil {
return total, err
}
if written != len(line) {
return total, io.ErrShortWrite
}
}
return total, nil
}
// SecretsError reports safe structured diagnostics for a failed explicit
// secrets-directory bootstrap. It intentionally does not unwrap filesystem or
// environment errors.
type SecretsError struct {
diagnostics []Diagnostic
}
// Error implements error without rendering filenames, paths, or file contents.
func (e *SecretsError) Error() string {
if e == nil {
return "configkit: secrets bootstrap failed"
}
count := 0
for _, diagnostic := range e.diagnostics {
if diagnostic.Severity == SeverityError {
count++
}
}
if count == 1 {
return "configkit: secrets bootstrap failed with 1 error"
}
return fmt.Sprintf("configkit: secrets bootstrap failed with %d errors", count)
}
// Diagnostics returns a deep copy of the bootstrap diagnostics.
func (e *SecretsError) Diagnostics() []Diagnostic {
if e == nil {
return nil
}
return cloneDiagnostics(e.diagnostics)
}
type secretsEnvironment struct {
lookup func(string) (string, bool)
set func(string, string) error
}
type secretsAccess struct {
openDirectory func(string) (*os.File, error)
lstat func(string) (fs.FileInfo, error)
openFile func(string) (*os.File, error)
}
type stagedSecret struct {
entry int
value string
}
// LoadSecretsDir explicitly loads immediate regular files from directory into
// previously unset process environment variables. Applications should call it
// once during startup, before concurrent work; Load never calls it.
// Dot-prefixed entries are ignored and omitted from the report.
func LoadSecretsDir(directory string) (*SecretsReport, error) {
return loadSecretsDir(directory, secretsEnvironment{lookup: os.LookupEnv, set: os.Setenv}, secretsAccess{
openDirectory: os.Open,
lstat: os.Lstat,
openFile: os.Open,
})
}
func loadSecretsDir(directory string, environment secretsEnvironment, access secretsAccess) (*SecretsReport, error) {
report := &SecretsReport{}
if strings.TrimSpace(directory) == "" {
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory path is required")
}
canonical, err := canonicalExistingPath(directory)
if err != nil {
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be canonicalized: "+strconv.Quote(cleanAbsolutePath(directory)))
}
info, err := os.Stat(canonical)
if err != nil {
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be inspected: "+strconv.Quote(canonical))
}
if !info.IsDir() {
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory is not a directory: "+strconv.Quote(canonical))
}
directoryFile, err := access.openDirectory(canonical)
if err != nil {
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be opened: "+strconv.Quote(canonical))
}
entries, readErr := readSecretsEntries(directoryFile)
closeErr := directoryFile.Close()
if readErr != nil {
return report, secretsFailure(classifySecretsFilesystemError(readErr), "secrets directory could not be enumerated: "+strconv.Quote(canonical))
}
if closeErr != nil {
return report, secretsFailure(CodeSecretsRead, "secrets directory could not be closed: "+strconv.Quote(canonical))
}
entryLimitExceeded := len(entries) > secretsEntryLimit
entries = slices.DeleteFunc(entries, func(entry fs.DirEntry) bool {
return strings.HasPrefix(entry.Name(), ".")
})
slices.SortFunc(entries, func(left, right fs.DirEntry) int { return strings.Compare(left.Name(), right.Name()) })
report.entries = make([]SecretFileResult, len(entries))
for index, entry := range entries {
name := entry.Name()
report.entries[index] = SecretFileResult{Name: name, Path: filepath.Join(canonical, name), Status: SecretFileNotApplied}
}
if entryLimitExceeded {
return report, secretsFailure(CodeSecretsLimitExceeded, "secrets directory exceeds the 1024-entry limit: "+strconv.Quote(canonical))
}
for index := range report.entries {
entry := &report.entries[index]
if !validEnvironmentName(entry.Name) {
entry.Status = SecretFileFailed
return report, secretsFailure(CodeSecretsInvalidName, "secret filename is not a valid environment name: "+strconv.Quote(entry.Path))
}
info, err := access.lstat(entry.Path)
if err != nil {
entry.Status = SecretFileFailed
return report, secretsFailure(classifySecretsFilesystemError(err), "secret file could not be inspected: "+strconv.Quote(entry.Path))
}
if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() {
entry.Status = SecretFileFailed
return report, secretsFailure(CodeSecretsNotRegular, "secret entry is not a regular file: "+strconv.Quote(entry.Path))
}
}
staged := make([]stagedSecret, 0, len(report.entries))
var consumed int64
for index := range report.entries {
entry := &report.entries[index]
if existing, present := environment.lookup(entry.Name); present {
entry.Status = SecretFileSkippedExisting
entry.ExistingEmpty = existing == ""
continue
}
remaining := secretsByteLimit - consumed
value, used, err := readSecretValue(entry.Path, remaining, access)
if err != nil {
entry.Status = SecretFileFailed
return report, secretsFailure(secretReadCode(err), secretReadMessage(err)+": "+strconv.Quote(entry.Path))
}
consumed += used
staged = append(staged, stagedSecret{entry: index, value: value})
}
for _, secret := range staged {
entry := &report.entries[secret.entry]
if existing, present := environment.lookup(entry.Name); present {
entry.Status = SecretFileSkippedExisting
entry.ExistingEmpty = existing == ""
continue
}
if err := environment.set(entry.Name, secret.value); err != nil {
entry.Status = SecretFileFailed
return report, secretsFailure(CodeSecretsApplyFailed, "secret environment variable could not be set for file: "+strconv.Quote(entry.Path))
}
entry.Status = SecretFileLoaded
}
return report, nil
}
func readSecretsEntries(directory *os.File) ([]fs.DirEntry, error) {
entries := make([]fs.DirEntry, 0)
for len(entries) <= secretsEntryLimit {
batch, err := directory.ReadDir(secretsEntryLimit + 1 - len(entries))
entries = append(entries, batch...)
if err == io.EOF {
return entries, nil
}
if err != nil {
return entries, err
}
if len(entries) > secretsEntryLimit {
return entries, nil
}
}
return entries, nil
}
type secretReadFailure int
const (
secretReadFilesystem secretReadFailure = iota
secretReadFileLimit
secretReadTotalLimit
secretReadNUL
)
type secretReadError struct {
kind secretReadFailure
err error
}
func (e *secretReadError) Error() string { return "secret read failed" }
func readSecretValue(path string, remaining int64, access secretsAccess) (string, int64, error) {
if remaining < 0 {
return "", 0, &secretReadError{kind: secretReadTotalLimit}
}
limit := min(secretFileByteLimit, remaining)
file, err := access.openFile(path)
if err != nil {
return "", 0, &secretReadError{kind: secretReadFilesystem, err: err}
}
data, readErr := io.ReadAll(io.LimitReader(file, limit+1))
closeErr := file.Close()
if readErr != nil {
return "", 0, &secretReadError{kind: secretReadFilesystem, err: readErr}
}
if closeErr != nil {
return "", 0, &secretReadError{kind: secretReadFilesystem, err: closeErr}
}
if int64(len(data)) > limit {
kind := secretReadFileLimit
if remaining < secretFileByteLimit {
kind = secretReadTotalLimit
}
return "", 0, &secretReadError{kind: kind}
}
if slices.Contains(data, byte(0)) {
return "", 0, &secretReadError{kind: secretReadNUL}
}
value := string(data)
if strings.HasSuffix(value, "\r\n") {
value = strings.TrimSuffix(value, "\r\n")
} else {
value = strings.TrimSuffix(value, "\n")
}
return value, int64(len(data)), nil
}
func validEnvironmentName(name string) bool {
if name == "" || !asciiEnvironmentFirst(name[0]) {
return false
}
for index := 1; index < len(name); index++ {
character := name[index]
if !asciiEnvironmentFirst(character) && (character < '0' || character > '9') {
return false
}
}
return true
}
func asciiEnvironmentFirst(character byte) bool {
return character == '_' || character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z'
}
func cleanAbsolutePath(path string) string {
absolute, err := filepath.Abs(path)
if err != nil {
return filepath.Clean(path)
}
return filepath.Clean(absolute)
}
func classifySecretsFilesystemError(err error) Code {
switch {
case errors.Is(err, fs.ErrNotExist):
return CodeSecretsMissing
case errors.Is(err, fs.ErrPermission):
return CodeSecretsPermission
default:
return CodeSecretsRead
}
}
func secretReadCode(err error) Code {
var failure *secretReadError
if errors.As(err, &failure) {
switch failure.kind {
case secretReadFileLimit, secretReadTotalLimit:
return CodeSecretsLimitExceeded
case secretReadNUL:
return CodeSecretsInvalidValue
case secretReadFilesystem:
return classifySecretsFilesystemError(failure.err)
}
}
return CodeSecretsRead
}
func secretReadMessage(err error) string {
var failure *secretReadError
if errors.As(err, &failure) {
switch failure.kind {
case secretReadFileLimit:
return "secret file exceeds the 1 MiB limit"
case secretReadTotalLimit:
return "eligible secret files exceed the 8 MiB total limit"
case secretReadNUL:
return "secret file contains a NUL byte"
}
}
return "secret file could not be read"
}
func secretsFailure(code Code, message string) *SecretsError {
return &SecretsError{diagnostics: []Diagnostic{{Code: code, Severity: SeverityError, Message: message}}}
}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"encoding/json" "encoding/json"
@@ -64,6 +64,41 @@ func TestLoadSecretsDirLoadsCanonicalSortedValues(t *testing.T) {
} }
} }
func TestLoadSecretsDirIgnoresDotEntries(t *testing.T) {
directory := t.TempDir()
if err := os.Mkdir(filepath.Join(directory, ".git"), 0o700); err != nil {
t.Fatal(err)
}
writeSecretTestFile(t, directory, ".gitignore", "CANARY\x00")
writeSecretTestFile(t, directory, ".DS_Store", "CANARY\x00")
if runtime.GOOS != "windows" {
if err := os.Symlink(filepath.Join(directory, "missing"), filepath.Join(directory, ".hidden-link")); err != nil {
t.Fatal(err)
}
}
access := testSecretsAccess()
access.lstat = func(path string) (fs.FileInfo, error) {
if strings.HasPrefix(filepath.Base(path), ".") {
t.Fatalf("inspected ignored entry %q", path)
}
return os.Lstat(path)
}
environment := map[string]string{}
report, err := loadSecretsDir(directory, mapSecretsEnvironment(environment), access)
if err != nil || len(report.Entries()) != 0 || len(environment) != 0 {
t.Fatalf("hidden-only directory: error = %v, report = %#v, environment = %#v", err, report.Entries(), environment)
}
writeSecretTestFile(t, directory, "SECRET", "value\n")
report, err = loadSecretsDir(directory, mapSecretsEnvironment(environment), access)
if err != nil {
t.Fatal(err)
}
want := []SecretFileResult{{Name: "SECRET", Path: filepath.Join(canonicalTestPath(t, directory), "SECRET"), Status: SecretFileLoaded}}
if !reflect.DeepEqual(report.Entries(), want) || !reflect.DeepEqual(environment, map[string]string{"SECRET": "value"}) {
t.Fatalf("mixed directory: report = %#v, environment = %#v", report.Entries(), environment)
}
}
func TestLoadSecretsDirPreservesExistingWithoutReading(t *testing.T) { func TestLoadSecretsDirPreservesExistingWithoutReading(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
writeSecretTestFile(t, directory, "EMPTY_WINNER", strings.Repeat("CANARY", int(secretFileByteLimit))) writeSecretTestFile(t, directory, "EMPTY_WINNER", strings.Repeat("CANARY", int(secretFileByteLimit)))

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"bytes" "bytes"

216
internal/engine/source.go Normal file
View File

@@ -0,0 +1,216 @@
package engine
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"sync/atomic"
)
const (
maxSourceBytes = 8 << 20
maxTotalBytes = 64 << 20
)
// Source is an opaque configuration input. Construct sources with File, Bytes,
// Reader, or NoSource; the zero value is invalid.
type Source struct {
kind sourceKind
path string
label string
data []byte
reader *readerSource
createErr sourceCreationError
}
type sourceKind uint8
const (
sourceInvalid sourceKind = iota
sourceFile
sourceBytes
sourceReader
sourceNone
)
type sourceCreationError uint8
const (
creationValid sourceCreationError = iota
creationInvalidPath
creationInvalidLabel
creationInvalidReader
creationTooLarge
)
type readerSource struct {
input io.Reader
used atomic.Bool
}
// File returns a source that reopens path for every load attempt.
func File(path string) Source {
if path == "" {
return Source{kind: sourceFile, createErr: creationInvalidPath}
}
return Source{kind: sourceFile, path: path, label: path}
}
// Bytes snapshots data immediately. Label must be nonempty and safe to display;
// constructor problems are reported when the source is consumed.
func Bytes(label string, data []byte) Source {
source := Source{kind: sourceBytes, label: label}
if label == "" {
source.createErr = creationInvalidLabel
return source
}
if len(data) > maxSourceBytes {
source.createErr = creationTooLarge
return source
}
source.data = bytes.Clone(data)
return source
}
// Reader returns a single-use source. ConfigKit consumes input but never closes it.
// Copies of the returned Source share the same single-use claim.
func Reader(label string, input io.Reader) Source {
source := Source{kind: sourceReader, label: label}
if label == "" {
source.createErr = creationInvalidLabel
return source
}
if nilReader(input) {
source.createErr = creationInvalidReader
return source
}
source.reader = &readerSource{input: input}
return source
}
// NoSource explicitly selects a defaults-only input. It creates no source record.
func NoSource() Source {
return Source{kind: sourceNone}
}
type sourceLimits struct {
perSource int64
remaining int64
}
func defaultSourceLimits() sourceLimits {
return sourceLimits{perSource: maxSourceBytes, remaining: maxTotalBytes}
}
type sourceRead struct {
data []byte
info *SourceInfo
}
func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead, []Diagnostic) {
if diagnostic := sourceConstructorDiagnostic(source); diagnostic != nil {
return sourceRead{}, []Diagnostic{*diagnostic}
}
if source.kind == sourceNone {
return sourceRead{}, nil
}
limit := min(limits.perSource, limits.remaining)
if limit < 0 {
limit = 0
}
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
var input io.Reader
switch source.kind {
case sourceFile:
absolute, err := filepath.Abs(source.path)
if err != nil {
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceRead, sourceID, "source path could not be resolved")}
}
info.Path = filepath.Clean(absolute)
canonical, err := filepath.EvalSymlinks(info.Path)
if err != nil {
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
}
info.Path = canonical
data, failure := readRegularFile(canonical, os.Stat, os.Open, limit)
if failure != nil {
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
}
return sourceRead{data: bytes.Clone(data), info: info}, nil
case sourceBytes:
input = bytes.NewReader(source.data)
case sourceReader:
if !source.reader.used.CompareAndSwap(false, true) {
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceReaderUsed, sourceID, "reader source has already been consumed")}
}
input = source.reader.input
default:
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
}
data, failure := readBoundedSource(input, limit)
if failure != nil {
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
}
return sourceRead{data: bytes.Clone(data), info: info}, nil
}
func sourceConstructorDiagnostic(source Source) *Diagnostic {
var message string
code := CodeSourceInvalid
switch source.createErr {
case creationValid:
if source.kind != sourceInvalid {
return nil
}
message = "source is invalid"
case creationInvalidPath:
message = "source path must not be empty"
case creationInvalidLabel:
message = "source label must not be empty"
case creationInvalidReader:
message = "source reader must not be nil"
case creationTooLarge:
code = CodeSourceLimitExceeded
message = "source exceeds the configured byte limit"
}
diagnostic := sourceDiagnostic(code, "", message)
return &diagnostic
}
func classifySourceError(err error, sourceID string) Diagnostic {
switch {
case errors.Is(err, fs.ErrNotExist):
return sourceDiagnostic(CodeSourceMissing, sourceID, "source does not exist")
case errors.Is(err, fs.ErrPermission):
return sourceDiagnostic(CodeSourcePermission, sourceID, "source cannot be accessed")
default:
return sourceDiagnostic(CodeSourceRead, sourceID, "source could not be read")
}
}
func sourceDiagnostic(code Code, sourceID, message string) Diagnostic {
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Message: message}
if sourceID != "" {
diagnostic.Primary = &Location{SourceID: sourceID}
}
return diagnostic
}
func nilReader(input io.Reader) bool {
if input == nil {
return true
}
value := reflect.ValueOf(input)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}

View File

@@ -1,6 +1,6 @@
//go:build darwin || linux //go:build darwin || linux
package configkit package engine
import ( import (
"context" "context"
@@ -26,7 +26,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
case "profile": case "profile":
opts.Profile = "fifo" opts.Profile = "fifo"
} }
_, err := Load(source, opts) _, err := testLoad(source, opts)
var loadErr *LoadError var loadErr *LoadError
if !errors.As(err, &loadErr) { if !errors.As(err, &loadErr) {
t.Fatalf("expected safe load error, got %v", err) t.Fatalf("expected safe load error, got %v", err)

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"io/fs" "io/fs"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"reflect" "reflect"
@@ -110,7 +110,7 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch
return canonicalDiagnostics(state.diagnostics) return canonicalDiagnostics(state.diagnostics)
} }
state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...) state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...)
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) { for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema, lookups) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path)) state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
} }
return canonicalDiagnostics(state.diagnostics) return canonicalDiagnostics(state.diagnostics)

View File

@@ -1,4 +1,4 @@
package configkit package engine
import ( import (
"errors" "errors"
@@ -14,7 +14,7 @@ func TestValidationCollectsSchemaAwareReportsAndNearestLocations(t *testing.T) {
Missing string `yaml:"missing"` Missing string `yaml:"missing"`
Dynamic map[string]any `yaml:"dynamic"` Dynamic map[string]any `yaml:"dynamic"`
} }
result, err := Load(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) { Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("missing"), "is required", Keys("primary")) validation.Error(Keys("missing"), "is required", Keys("primary"))
validation.Error(Keys("dynamic", "missing"), "dynamic entry is required") validation.Error(Keys("dynamic", "missing"), "dynamic entry is required")
@@ -58,7 +58,7 @@ func TestValidationRejectsUnknownStructPaths(t *testing.T) {
type config struct { type config struct {
Known string `yaml:"known"` Known string `yaml:"known"`
} }
result, err := Load(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) { Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("unknown"), "must be configured") validation.Error(Keys("unknown"), "must be configured")
}, },
@@ -73,7 +73,7 @@ func TestValidationMutationFailsWithoutPublishingResult(t *testing.T) {
Value int `yaml:"value"` Value int `yaml:"value"`
Items map[string]int `yaml:"items"` Items map[string]int `yaml:"items"`
} }
result, err := Load(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(configuration *config, _ *Validation) { Validate: func(configuration *config, _ *Validation) {
configuration.Value = 2 configuration.Value = 2
configuration.Items["one"] = 2 configuration.Items["one"] = 2
@@ -127,7 +127,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
validation.Warning(Keys("value"), "validation warning") validation.Warning(Keys("value"), "validation warning")
}, },
} }
result, err := Load(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition}) result, err := testLoad(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -148,7 +148,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil }, Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil },
Validate: func(_ *config, _ *Validation) { events = append(events, "validate") }, Validate: func(_ *config, _ *Validation) { events = append(events, "validate") },
} }
result, err := Load(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition}) result, err := testLoad(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition})
assertLoadDiagnostic(t, result, err, CodeVersionError) assertLoadDiagnostic(t, result, err, CodeVersionError)
if !reflect.DeepEqual(events, []string{"version"}) { if !reflect.DeepEqual(events, []string{"version"}) {
t.Fatalf("events = %#v", events) t.Fatalf("events = %#v", events)
@@ -157,7 +157,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
t.Run("resolution error stops validation", func(t *testing.T) { t.Run("resolution error stops validation", func(t *testing.T) {
var validated bool var validated bool
result, err := Load(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, resolution *Resolution) error { Resolve: func(_ *config, resolution *Resolution) error {
resolution.Error(Keys("value"), "cannot resolve") resolution.Error(Keys("value"), "cannot resolve")
return nil return nil
@@ -172,7 +172,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
t.Run("decode error stops application hooks", func(t *testing.T) { t.Run("decode error stops application hooks", func(t *testing.T) {
var resolved, validated bool var resolved, validated bool
result, err := Load(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil }, Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil },
Validate: func(_ *config, _ *Validation) { validated = true }, Validate: func(_ *config, _ *Validation) { validated = true },
}}) }})
@@ -190,7 +190,7 @@ func TestCompleteSingleSourceLifecycleKeepsMetadataAgreement(t *testing.T) {
Input int `yaml:"input"` Input int `yaml:"input"`
Derived int `yaml:"derived"` Derived int `yaml:"derived"`
} }
result, err := Load(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Derived: 1} }, Defaults: func() config { return config{Derived: 1} },
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input")) Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input"))

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "reflect" import "reflect"
@@ -34,10 +34,10 @@ type valueOperation struct {
} }
func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) { func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) {
if current == secretType { if o.schema.secrets.Type != nil && current == o.schema.secrets.Type {
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"} return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
} }
builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool)} builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool), secrets: o.schema.secrets}
descriptor, issue := builder.describe(current, path) descriptor, issue := builder.describe(current, path)
if issue != nil { if issue != nil {
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"} return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"}

View File

@@ -1,4 +1,4 @@
package configkit package engine
import "testing" import "testing"
@@ -15,7 +15,7 @@ func TestVersionPathsShareCanonicalSequenceSemantics(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
var checked Version var checked Version
result, err := Load[config](Bytes("version.yml", []byte("versions: ["+value+"]\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad[config](Bytes("version.yml", []byte("versions: ["+value+"]\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { checked = v; return nil }}, Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { checked = v; return nil }},
}}) }})
want := Version{} want := Version{}
@@ -49,7 +49,7 @@ func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := Load[map[string]string](Bytes("version.yml", []byte("'"+key+"': 4\n")), Options[map[string]string]{Definition: Definition[map[string]string]{ result, err := testLoad[map[string]string](Bytes("version.yml", []byte("'"+key+"': 4\n")), Options[map[string]string]{Definition: Definition[map[string]string]{
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error {
if !v.Present || v.Text != "4" { if !v.Present || v.Text != "4" {
t.Errorf("checked = %#v", v) t.Errorf("checked = %#v", v)
@@ -77,7 +77,7 @@ func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) {
type config struct { type config struct {
Versions any `yaml:"versions"` Versions any `yaml:"versions"`
} }
_, err := Load[config](Bytes("version.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: test.path, Check: func(v Version, validation *Validation) error { _, err := testLoad[config](Bytes("version.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: test.path, Check: func(v Version, validation *Validation) error {
if v.Present { if v.Present {
t.Errorf("wrong segment kind found version: %#v", v) t.Errorf("wrong segment kind found version: %#v", v)
} }

274
lifecycle.go Normal file
View File

@@ -0,0 +1,274 @@
package configkit
import (
"errors"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// 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 entry-state input provenance, surviving-input presence, and
// audited application resolution changes. A resolver must not retain or
// concurrently use the context.
type Resolution struct{ value *engine.Resolution }
// VersionRule declares the input path and application checker for a schema version.
// At that path, string and decimal-integer input is supplied as Version.Text.
type VersionRule struct {
Path Path
Check func(Version, *Validation) error
}
// Version preserves the absent, null, and textual states of a schema-version input.
// Text is the exact spelling of accepted string or decimal-integer input.
type Version struct {
Present bool
Null bool
Text string
}
// Validation collects safe application diagnostics during a supported callback.
// A callback must not retain the context or use it concurrently.
type Validation struct{ value *engine.Validation }
// Result is a successful independently owned configuration load.
type Result[T any] struct {
Config T
inspection *engine.Inspection
}
// 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) {
options := make([]engine.Options[T], len(supplied))
for index := range supplied {
options[index] = toEngineOptions(supplied[index])
}
loaded, err := engine.Load(source.toEngine(), secretBinding(), options...)
if err != nil {
var loadError *engine.LoadError
if errors.As(err, &loadError) {
return nil, &LoadError{value: loadError}
}
return nil, &LoadError{}
}
return &Result[T]{Config: loaded.Config, inspection: loaded.Inspection}, nil
}
// Metadata returns detached effective presence, origin, and history records.
func (r *Result[T]) Metadata() Metadata {
if r == nil || r.inspection == nil {
return Metadata{}
}
return Metadata{value: r.inspection.Metadata()}
}
// Sources returns a detached list of sources used by the load.
func (r *Result[T]) Sources() []SourceInfo {
if r == nil || r.inspection == nil {
return nil
}
values := r.inspection.Sources()
if values == nil {
return nil
}
result := make([]SourceInfo, len(values))
for index := range values {
result[index] = fromEngineSourceInfo(values[index])
}
return result
}
// Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Result[T]) Diagnostics() []Diagnostic {
if r == nil || r.inspection == nil {
return nil
}
values := r.inspection.Diagnostics()
if values == nil {
return nil
}
result := make([]Diagnostic, len(values))
for index := range values {
result[index] = fromEngineDiagnostic(values[index])
}
return result
}
// IsExplicit reports whether surviving input explicitly defined path.
func (r *Resolution) IsExplicit(path Path) bool {
return r.engineContext().IsExplicit(toEnginePath(path))
}
// InputInfo returns detached entry-state metadata for path. The entry state is
// the decoded configuration, including retained static defaults, immediately
// before resolution starts. It is fixed while resolution records mutate the
// working configuration; unknown or shape-mismatched paths return false.
//
// InputInfo is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) {
value, ok := r.engineContext().InputInfo(toEnginePath(path))
return fromEngineFieldInfo(value), ok
}
// Source returns a detached record for a source acquired by this load. Empty or
// unknown IDs return false and do not perform input, filesystem, or credential
// access. A source path is present only when that acquired source has one.
//
// Source is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) Source(id string) (SourceInfo, bool) {
value, ok := r.engineContext().Source(id)
return fromEngineSourceInfo(value), ok
}
// 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) {
r.engineContext().Record(toEnginePath(path), engine.ChangeKind(kind), reason, toEnginePaths(dependencies)...)
}
// Error reports a safe, value-free resolution error at path.
func (r *Resolution) Error(path Path, message string, related ...Path) {
r.engineContext().Error(toEnginePath(path), message, toEnginePaths(related)...)
}
// Warning reports a safe, value-free resolution warning at path.
func (r *Resolution) Warning(path Path, message string, related ...Path) {
r.engineContext().Warning(toEnginePath(path), message, toEnginePaths(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) {
engine.Default(resolution.engineContext(), toEnginePath(path), target, value, reason, toEnginePaths(dependencies)...)
}
// 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) {
engine.Normalize(resolution.engineContext(), toEnginePath(path), target, value, reason, toEnginePaths(dependencies)...)
}
// 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.engineContext().Error(toEnginePath(path), message, toEnginePaths(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.engineContext().Warning(toEnginePath(path), message, toEnginePaths(related)...)
}
// Pass nil through so the engine remains the owner of the callback guard.
func (r *Resolution) engineContext() *engine.Resolution {
if r == nil {
return nil
}
return r.value
}
func (v *Validation) engineContext() *engine.Validation {
if v == nil {
return nil
}
return v.value
}
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
result := engine.Options[T]{
Definition: toEngineDefinition(options.Definition),
Imports: options.Imports,
Profile: options.Profile,
}
if options.Filesystem != nil {
result.Filesystem = &engine.FilesystemContext{BaseDir: options.Filesystem.BaseDir, Root: options.Filesystem.Root}
}
if options.Overrides != nil {
result.Overrides = make([]engine.Override, len(options.Overrides))
for index := range options.Overrides {
result.Overrides[index] = engine.Override{
Path: toEnginePath(options.Overrides[index].Path), Value: options.Overrides[index].Value, Flag: options.Overrides[index].Flag,
}
}
}
return result
}
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
result := engine.Definition[T]{Defaults: definition.Defaults}
if definition.Resolve != nil {
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
return definition.Resolve(configuration, &Resolution{value: resolution})
}
}
if definition.Validate != nil {
result.Validate = func(configuration *T, validation *engine.Validation) {
definition.Validate(configuration, &Validation{value: validation})
}
}
if definition.Version != nil {
rule := definition.Version
result.Version = &engine.VersionRule{Path: toEnginePath(rule.Path)}
if rule.Check != nil {
result.Version.Check = func(version engine.Version, validation *engine.Validation) error {
return rule.Check(Version{Present: version.Present, Null: version.Null, Text: version.Text}, &Validation{value: validation})
}
}
}
if definition.Secret != nil {
result.Secret = func(path engine.Path) bool { return definition.Secret(fromEnginePath(path)) }
}
return result
}
func toEnginePaths(paths []Path) []engine.Path {
if paths == nil {
return nil
}
result := make([]engine.Path, len(paths))
for index := range paths {
result[index] = toEnginePath(paths[index])
}
return result
}

View File

@@ -1,38 +1,22 @@
package configkit package configkit
import ( import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Metadata provides detached effective presence/origin and chronological history // Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths. // queries for a load. Its zero value contains no paths.
type Metadata struct { type Metadata struct{ value engine.Metadata }
effectivePaths map[string]Path
historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
}
// Lookup returns information for an effective path. Unknown and invalid paths // Lookup returns information for an effective path. Unknown and invalid paths
// return false. // return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) { func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) { value, ok := m.value.Lookup(toEnginePath(path))
return FieldInfo{}, false return fromEngineFieldInfo(value), ok
}
info, ok := m.fields[path.String()]
if !ok {
return FieldInfo{}, false
}
return cloneFieldInfo(info), true
} }
// IsExplicit reports whether a surviving input contribution explicitly defined // IsExplicit reports whether a surviving input contribution explicitly defined
// path. // path.
func (m Metadata) IsExplicit(path Path) bool { func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path) return m.value.IsExplicit(toEnginePath(path))
return ok && info.Explicit
} }
// History returns detached definition and resolution records for path. A record's // History returns detached definition and resolution records for path. A record's
@@ -40,18 +24,15 @@ func (m Metadata) IsExplicit(path Path) bool {
// Removed historical paths can have history even though Lookup returns false. // Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil. // Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change { func (m Metadata) History(path Path) []Change {
if !m.hasHistoricalPath(path) { values := m.value.History(toEnginePath(path))
if values == nil {
return nil return nil
} }
history, ok := m.history[path.String()] result := make([]Change, len(values))
if !ok { for index := range values {
return nil result[index] = fromEngineChange(values[index])
} }
cloned := make([]Change, len(history)) return result
for i, change := range history {
cloned[i] = cloneChange(change)
}
return cloned
} }
// HistoricalPaths returns the paths observed during loading, including effective // HistoricalPaths returns the paths observed during loading, including effective
@@ -66,75 +47,10 @@ func (m Metadata) History(path Path) []Change {
// a path remains effective. Enumeration exposes no configuration values and does // a path remains effective. Enumeration exposes no configuration values and does
// not perform another load or resolve secrets. // not perform another load or resolve secrets.
func (m Metadata) HistoricalPaths() []Path { func (m Metadata) HistoricalPaths() []Path {
pointers := make([]string, 0, len(m.historicalPaths)) values := m.value.HistoricalPaths()
for pointer := range m.historicalPaths { result := make([]Path, len(values))
pointers = append(pointers, pointer) for index := range values {
} result[index] = fromEnginePath(values[index])
sort.Strings(pointers)
paths := make([]Path, 0, len(pointers))
for _, pointer := range pointers {
// The index contains only canonical pointers produced by Path.String.
path, _ := ParsePath(pointer)
paths = append(paths, path)
}
return paths
}
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers.
func matchesPathShape(query, candidate Path) bool {
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
return false
}
for i, segment := range query.segments {
actual := candidate.segments[i]
if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment)
if !ok || index != actual.index {
return false
}
} else if segment.kind == pathSegmentIndex {
return false
}
}
return true
}
func (m Metadata) hasHistoricalPath(path Path) bool {
if !path.valid() {
return false
}
for _, candidate := range m.historicalPaths[path.String()] {
if matchesPathShape(path, candidate) {
return true
}
}
return false
}
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
m.effectivePaths = make(map[string]Path)
m.historicalPaths = make(map[string][]Path)
walkDocument(final, Path{}, func(path Path, _ *document.Node) { m.effectivePaths[path.String()] = path.clone() })
add := func(path Path, _ *document.Node) {
pointer := path.String()
for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) {
return
}
}
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
}
walkDocument(final, Path{}, add)
for _, tree := range earlier {
walkDocument(tree, Path{}, add)
}
for _, application := range applications {
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
prefix := Path{}
for _, segment := range application.path.segments {
prefix = prefix.Key(segment.key)
}
walkDocument(application.value, prefix, add)
} }
return result
} }

Some files were not shown because too many files have changed in this diff Show More