Add selected profile overlays

This commit is contained in:
2026-09-06 21:03:45 +00:00
parent 71a73bcfd8
commit ef0e6078f7
17 changed files with 897 additions and 81 deletions

View File

@@ -5,11 +5,11 @@ An opinionated Go library for applications that use YAML configuration files.
ConfigKit is in early implementation. Its current loader accepts one strict YAML ConfigKit is in early implementation. Its current 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
can check an application schema version. It returns detached metadata and safe can overlay one selected fixed profile and check an application schema version. It
diagnostics. returns detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver, Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. then report domain errors and warnings through read-only semantic validation.
Profiles, overrides, secrets, and rendering are still under development. Overrides, secrets, and rendering are still under development.
```go ```go
type Config struct { type Config struct {

View File

@@ -6,9 +6,12 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// precedence layers. Claims remain ordered by source and declaration traversal; // precedence layers. Claims remain ordered by source and declaration traversal;
// origins retain every source that contributes to a composite mapping. // origins retain every source that contributes to a composite mapping.
type composedDocument struct { type composedDocument struct {
root *document.Node root *document.Node
claims []compositionClaim claims []compositionClaim
origins map[string]Origin origins map[string]Origin
overlays []overlayRecord
inputBytes int64
inputNodes int
} }
type compositionClaim struct { type compositionClaim struct {

View File

@@ -20,28 +20,29 @@ The zero `Source` is invalid. Constructor argument failures are retained inside
opaque source and become safe structured diagnostics when the source is consumed. opaque source and become safe structured diagnostics when the source is consumed.
File sources can observe file changes between consumptions; byte sources cannot. File sources can observe file changes between consumptions; byte sources cannot.
Each source is limited to 8 MiB. An imported load is limited to 64 MiB across all Each source is limited to 8 MiB. A load using imports or an active profile is limited
accepted sources, 1,024 accepted sources, 64 active import edges, and 1,000,000 to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
input nodes in aggregate. Glob expansion separately permits 1,000,000 visited edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
directory entries, including nonmatches. Reads request at most the remaining 1,000,000 visited directory entries, including nonmatches. Reads request at most the
allowance plus one byte so excess data is rejected rather than silently truncated. remaining allowance plus one byte so excess data is rejected rather than silently
Data returned together with a read error is not accepted as partial input. truncated. Data returned together with a read error is not accepted as partial input.
## Single-source loading ## Single-source loading
`Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration `Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration
root `T` must satisfy the application schema contract below. With no options it root `T` must satisfy the application schema contract below. With no options it
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied, starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
runs exactly once after successful source parsing, schema construction, and optional runs exactly once after successful source parsing, schema construction, optional
schema-version checking. Its result is detached before input is applied. schema-version checking, and selected-profile loading. Its result is detached before
input is applied.
The current lifecycle is: The current lifecycle is:
```text ```text
validate options → parse the selected source → traverse/compose enabled imports validate options → parse the selected source → traverse/compose enabled imports
→ build schema → check the optional application version → create and clone defaults → build schema → check the optional application version → apply a selected profile
→ strictly decode → recorded application resolution → semantic validation → create and clone defaults → strictly decode → recorded application resolution
→ snapshot configuration and metadata → semantic validation → snapshot configuration and metadata
``` ```
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic `File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
@@ -51,13 +52,12 @@ contributes no source, and continues through defaults, decode, and projection. A
missing file remains an error; it is never interpreted as no input. missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver, The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
semantic validator, explicit path and glob imports, and filesystem context. Active semantic validator, explicit path and glob imports, one selected profile, and
profiles, overrides, and dynamic secret classification return `options.unavailable` filesystem context. Overrides and dynamic secret classification return
because their lifecycle stages are not implemented. The reserved profile spelling `options.unavailable` because their lifecycle stages are not implemented. `NoSource`
`default` selects no active profile. `NoSource` rejects imports, active profiles, and filesystem context as invalid combinations.
additionally rejects imports, active profiles, and filesystem context as invalid Input containing a reserved `_configkit` mapping key is rejected while imports are
combinations. Input containing a reserved `_configkit` mapping key is rejected while disabled.
imports are disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
@@ -79,13 +79,16 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
diagnostic and suppresses the returned error text. Warnings alone are retained on a diagnostic and suppresses the returned error text. Warnings alone are retained on a
successful result. Callback panics remain programming failures. successful result. Callback panics remain programming failures.
### Base/default metadata ### Layered input/default metadata
`Result.Metadata` describes every final canonical path. Exact surviving input paths `Result.Metadata` describes every final canonical path. Exact surviving input paths
are explicit and have a base origin; omitted paths have a default origin. An explicit are explicit and have a base or profile origin; omitted paths have a default origin.
parent mapping does not make omitted children explicit. Null state and available An explicit parent mapping does not make omitted children explicit. Null state and
source coordinates are retained. `History` records the Go-zero or application available source coordinates are retained. `History` records the Go-zero or application
default baseline followed by exact base definitions, including equal-valued input. default baseline followed by exact base or profile definitions, including
equal-valued input. Profile replacement removes displaced descendants from effective
metadata while retaining their base definition and the parent replacement operation
in history.
All metadata queries and returned nested records are detached. All metadata queries and returned nested records are detached.
### Recorded application resolution ### Recorded application resolution
@@ -181,6 +184,43 @@ traversal order. Traversal order never authorizes replacement. Composition clone
the resulting tree and its ordered claim records rather than mutating or aliasing an the resulting tree and its ordered claim records rather than mutating or aliasing an
input document. input document.
## Selected profiles
`Options.Profile` selects at most one overlay by name. Names must match
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
`default` select no profile, and `profiles/default.yml` is never loaded implicitly.
Every other name resolves only to `<configuration-root>/profiles/<name>.yml`, with
no extension fallback, case normalization, inheritance, or profile stack. A selected
missing profile is an error.
The profile is a separately policy-validated YAML mapping. It is loaded after the
base import graph is composed and its schema version is checked, but before defaults
and typed decoding. Profile bytes, nodes, and source records count toward the same
aggregate limits as base inputs. Its canonical path must remain within the configured
root and must not duplicate a file already present in the base graph.
Overlay is intentionally distinct from strict composition:
| Existing lower node | Incoming profile node | Result |
| --- | --- | --- |
| Absent | Value | Retain the profile value |
| Value | Absent | Retain the lower value |
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
cross-kind replacement is allowed when the final application schema accepts it.
Replacing a subtree removes all displaced descendants from effective presence and
origin data; history retains the displaced definitions and identifies the parent
replacement. Surviving profile paths have profile origins, while recursively shared
mapping containers retain their contributing locations.
A profile may not contain `_configkit` anywhere, contribute the configured schema
version, or replace an ancestor of that version with a scalar or null. These rules
apply even if the supplied version text would be unchanged. Profiles cannot mask a
base parse, composition, or version failure because those checks run first.
## Explicit imports ## Explicit imports
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root

View File

@@ -3,7 +3,8 @@
This is the contributor entry point for ConfigKit. Read the task-specific owners This is the contributor entry point for ConfigKit. Read the task-specific owners
below before making changes. The repository is in early implementation: its public below before making changes. The repository is in early implementation: its public
loader currently covers strict selected sources, explicit path/glob import graphs, loader currently covers strict selected sources, explicit path/glob import graphs,
composition, typed defaults, schema-version checks, recorded application resolution, composition, selected profile overlays, typed defaults, schema-version checks,
recorded application resolution,
semantic validation, and metadata. The semantic validation, and metadata. The
[component overview](internal/overview.md) owns the implementation inventory. [component overview](internal/overview.md) owns the implementation inventory.

View File

@@ -155,6 +155,17 @@ Assigned to selected-profile overlay:
declared defaults, inheritance, arbitrary profile paths, or validating every declared defaults, inheritance, arbitrary profile paths, or validating every
unselected overlay. unselected overlay.
Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`,
`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge,
selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error,
directive/version, containment, and budget cases adapt the assigned tests. ConfigKit
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
retains displaced descendant history, rejects any profile directive or version-path
contribution, and counts the profile in aggregate budgets. Narratio's declared
default, multi-overlay validation, inheritance-like application conventions, and
null/kind restrictions were not retained.
### Effective inspection and provenance ### Effective inspection and provenance
Assigned to layered metadata, redaction, rendering, and explanation: Assigned to layered metadata, redaction, rendering, and explanation:

View File

@@ -13,6 +13,8 @@ module and package documentation boundary. Its implemented public primitives are
- a private strict composer in `compose.go` that joins only disjoint document-tree - a private strict composer in `compose.go` that joins only disjoint document-tree
definitions, retains ordered claims and composite mapping origins, reports every definitions, retains ordered claims and composite mapping origins, reports every
conflicting location, and never aliases an input tree; 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`;
- a per-operation private reflection descriptor graph in `schema.go` for supported - a per-operation private reflection descriptor graph in `schema.go` for supported
types, YAML field binding, inlining, secret tags, recursion, and semantic paths; types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
- descriptor-driven canonical projection, detached typed cloning, cycle/limit - descriptor-driven canonical projection, detached typed cloning, cycle/limit
@@ -31,7 +33,7 @@ module and package documentation boundary. Its implemented public primitives are
`validation.go`, completing the public callback lifecycle after composed input. `validation.go`, completing the public callback lifecycle after composed input.
The [configuration contract](../config.md) owns implemented input, schema, decoding, The [configuration contract](../config.md) owns implemented input, schema, decoding,
loading lifecycle, path imports, composition, version checking, recorded resolution, loading lifecycle, path imports, composition, profile overlay, version checking, recorded resolution,
semantic validation, metadata, and ownership behavior. semantic validation, metadata, and ownership behavior.
The source-extraction inventory records the application foundations assigned to The source-extraction inventory records the application foundations assigned to

View File

@@ -2,7 +2,7 @@
## Status and ownership ## Status and ownership
Status: implementation in progress; Stages 1–9 are complete and later stages are Status: implementation in progress; Stages 1–13 are complete and later stages are
pending. pending.
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
@@ -982,7 +982,31 @@ Acceptance:
## Stage 13 — Implement profile overlays ## Stage 13 — Implement profile overlays
Status: pending. Status: complete.
Completion evidence (2026-09-06):
- Added a private non-aliasing recursive overlay that merges mappings by key and
atomically replaces scalars, sequences, empty sequences, nulls, and cross-kind
subtrees. Replacement deletes stale descendant origins and records every displaced
definition plus the parent operation for later history.
- Added exact selected-profile loading from `profiles/<name>.yml`, with constrained
names, inactive empty/`default` selection, canonical root containment, base-graph
duplicate rejection, deterministic profile source ownership, and shared aggregate
source/byte/node limits. No path selection, extension fallback, inheritance, or
implicit default file is supported.
- Applied profiles only after base import composition and version checking and before
defaults and strict decoding. Profiles reject `_configkit` at any depth, direct
version contributions, and scalar/null replacements of version ancestors; base
parse and version failures therefore cannot be masked.
- Tests cover recursive siblings, scalar/full/empty-sequence/null/cross-kind
replacement, lower defaults, profile source and origin attribution, removed-child
and stale-index history, selection errors, fixed extension, directives, version
placement, base aliases, parse ordering, filesystem context, symlink escape, input
budgets, and non-aliasing. The
[current profile contract](../config.md#selected-profiles) owns the behavior.
Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
and `git diff --check` with Go 1.26.7 on linux/amd64.
Read: feature [profiles](initial.md#profiles), composition/overlay table. Read: feature [profiles](initial.md#profiles), composition/overlay table.

View File

@@ -450,28 +450,9 @@ origin is a resolution operation, with input origins retained separately.
## Composition and overlay ## Composition and overlay
Strict base composition is implemented and now owned by the Strict base composition and selected-profile overlay are implemented and owned by
[current configuration contract](../config.md#strict-document-composition). the [current composition contract](../config.md#strict-document-composition) and
Intentional profile overlay remains target behavior. Its exhaustive operation table [profile contract](../config.md#selected-profiles).
is below; “value” includes scalar, sequence, mapping, and null, while absent means no
node rather than null.
| Existing/lower node | Incoming/higher node | Overlay |
| --- | --- | --- |
| Absent | Absent | Absent |
| Absent | Value | Incoming |
| Value | Absent | Existing |
| Mapping | Mapping | Recurse by key |
| Any other pair of present nodes | Any other pair of present nodes | Replace entire existing node |
A mapping can replace a lower scalar, sequence, or null during overlay, subject to
the final schema. Sequences are atomic and the higher sequence replaces the entire
lower sequence in an overlay. `[]` clears a sequence. There is no append, union,
index merge, or mapping-clear operator.
Replacing a parent must not leave effective metadata for removed children.
Sequence histories describe whole-sequence replacement; index origins refer to
the surviving sequence, not an attempted correspondence with previous indices.
## Explicit imports and source identity ## Explicit imports and source identity
@@ -481,18 +462,9 @@ filesystem context, and budgets are implemented and now owned by the
## Profiles ## Profiles
Load only the composed base unless the application selects a profile. The API Selected profiles are implemented and owned by the
accepts a name matching `[a-zA-Z0-9_-]+`, not an arbitrary path. The empty name [current profile contract](../config.md#selected-profiles). Applications continue
and reserved lowercase name `default` both mean no overlay; a literal to own the flags and commands that choose a profile name.
`profiles/default.yml` is never loaded. Other names resolve exactly to
`<configuration-root>/profiles/<name>.yml`, without extension fallback or case
normalization. A selected missing profile is an error.
A profile is one policy-validated YAML mapping applied using the overlay table.
It must not contain `_configkit`, introduce imports, or alter the application's
declared schema-version path. Reject these directives even if their values
would be unchanged. There is no profile inheritance or multiple-profile stack.
Applications own flags and commands that select this behavior.
## Static defaults and null ## Static defaults and null
@@ -597,8 +569,8 @@ Automatic migration is deferred.
## Consumer API design ## Consumer API design
The core declarations, callback lifecycle, strict composition, and explicit path The core declarations, callback lifecycle, strict composition, explicit imports,
imports are implemented; their current contract is owned by and selected profiles are implemented; their current contract is owned by
[configuration input](../config.md) and Go reference comments. Remaining declarations [configuration input](../config.md) and Go reference comments. Remaining declarations
and behavior in this section are target design, not implemented Go reference and behavior in this section are target design, not implemented Go reference
documentation or a frozen naming commitment. Declarations omit method bodies and documentation or a frozen naming commitment. Declarations omit method bodies and
@@ -614,7 +586,7 @@ and verification against consumer models.
`Load`, `Definition`, `Options`, `FilesystemContext`, `Override`, `VersionRule`, `Load`, `Definition`, `Options`, `FilesystemContext`, `Override`, `VersionRule`,
`Version`, `Validation`, and `Result` are declared. The current loader supports the `Version`, `Validation`, and `Result` are declared. The current loader supports the
callback and path-import subset described by the [current contract](../config.md); callback and path-import subset described by the [current contract](../config.md);
later profile, override, secret, and rendering behavior below remains target work and later override, secret, and rendering behavior below remains target work and
unavailable options fail explicitly rather than being ignored. unavailable options fail explicitly rather than being ignored.
```go ```go
@@ -660,8 +632,8 @@ ConfigKit does not synchronize application-owned captured state.
`Source`, its constructors, and import filesystem context are implemented; `Source`, its constructors, and import filesystem context are implemented;
[`source.go`](../../source.go) and the [configuration input contract](../config.md#sources) [`source.go`](../../source.go) and the [configuration input contract](../config.md#sources)
own their current behavior. Applications must use non-secret source labels because own their current behavior. Applications must use non-secret source labels because
labels appear in diagnostics and source reports. Profile-related filesystem behavior labels appear in diagnostics and source reports. Filesystem options do not change
remains target work. Filesystem options do not change application field path meanings. application field path meanings.
`NoSource()` explicitly selects defaults-only loading. It contributes no document `NoSource()` explicitly selects defaults-only loading. It contributes no document
or file source entry and marks no fields explicit by itself; CLI overrides still or file source entry and marks no fields explicit by itself; CLI overrides still

View File

@@ -114,6 +114,10 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)} return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
} }
composed, diagnostics := composeDocuments(state.documents...) composed, diagnostics := composeDocuments(state.documents...)
if composed != nil {
composed.inputBytes = state.usedBytes
composed.inputNodes = state.usedNodes
}
return composed, cloneSourceInfos(state.sources), diagnostics return composed, cloneSourceInfos(state.sources), diagnostics
} }

42
load.go
View File

@@ -17,8 +17,8 @@ type Definition[T any] struct {
} }
// Options contains the application definition and per-load input selections. // Options contains the application definition and per-load input selections.
// Imports enables explicit path and glob imports; later precedence layers remain // Imports enables explicit path and glob imports; Profile selects one fixed profile
// unavailable until their owning features are implemented. // overlay. Explicit overrides remain unavailable until implemented.
type Options[T any] struct { type Options[T any] struct {
Definition Definition[T] Definition Definition[T]
Imports bool Imports bool
@@ -94,9 +94,9 @@ func (r *Result[T]) Diagnostics() []Diagnostic {
return cloneDiagnostics(r.diagnostics) return cloneDiagnostics(r.diagnostics)
} }
// Load parses and strictly decodes zero or one selected source plus explicitly // Load parses and strictly decodes zero or one selected source, explicitly enabled
// enabled imports. It accepts at most one Options value and returns no result when // imports, and one selected profile. It accepts at most one Options value and
// 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, supplied ...Options[T]) (*Result[T], error) {
accumulator := diagnosticAccumulator{} accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied) options, diagnostics := selectOptions(source, supplied)
@@ -127,7 +127,7 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
if parsed.source != nil { if parsed.source != nil {
accumulator.sources = append(accumulator.sources, *parsed.source) accumulator.sources = append(accumulator.sources, *parsed.source)
} }
if options.Filesystem != nil { if options.Filesystem != nil && !activeProfile(options.Profile) {
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil { if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
accumulator.add(*diagnostic) accumulator.add(*diagnostic)
return nil, accumulator.loadError() return nil, accumulator.loadError()
@@ -153,6 +153,26 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
if hasErrors(versionDiagnostics) { if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
if activeProfile(options.Profile) {
if composition == nil {
composition, diagnostics = composeDocuments(inputDocument)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
composition.inputBytes = parsed.bytes
composition.inputNodes = countInputNodes(parsed.document)
}
composition, accumulator.sources, diagnostics = loadProfileDocument(
source, options.Filesystem, parsed, composition, accumulator.sources,
options.Profile, options.Definition.Version, defaultImportLimits(),
)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
inputDocument = composition.root
}
defaults := reflect.Zero(reflect.TypeFor[T]()) defaults := reflect.Zero(reflect.TypeFor[T]())
if options.Definition.Defaults != nil { if options.Definition.Defaults != nil {
@@ -171,6 +191,9 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
configuration := decoded.value.Interface().(T) configuration := decoded.value.Interface().(T)
metadata := metadataFromDecoded(decoded) metadata := metadataFromDecoded(decoded)
if composition != nil {
applyOverlayMetadata(&metadata, composition.overlays)
}
final := decoded.final final := decoded.final
var resolutionSnapshots []resolutionSnapshot var resolutionSnapshots []resolutionSnapshot
if options.Definition.Resolve != nil { if options.Definition.Resolve != nil {
@@ -213,8 +236,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
if definition.Secret != nil { if definition.Secret != nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary"))
} }
if options.Profile != "" && options.Profile != "default" { if !validProfileName(options.Profile) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration profiles are not available in this release boundary")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
} }
if options.Filesystem != nil { if options.Filesystem != nil {
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...) diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
@@ -228,6 +251,9 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" { if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
} }
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
}
if definition.Version != nil { if definition.Version != nil {
if !definition.Version.Path.valid() { if !definition.Version.Path.valid() {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))

View File

@@ -34,6 +34,43 @@ func metadataFromDecoded(decoded decodedConfiguration) Metadata {
return metadata return metadata
} }
func applyOverlayMetadata(metadata *Metadata, records []overlayRecord) {
for _, record := range records {
for _, displaced := range record.displaced {
pointer := displaced.path.String()
history := metadata.history[pointer]
profileIndex := -1
for index := len(history) - 1; index >= 0; index-- {
if history[index].Origin.Layer == LayerProfile {
profileIndex = index
break
}
}
baseChange := Change{Path: displaced.path.clone(), Origin: cloneOrigin(displaced.origin)}
if len(history) != 0 && history[len(history)-1].Origin.Layer == LayerDefault {
baseChange.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)}
}
if profileIndex >= 0 {
history = append(history[:profileIndex], append([]Change{baseChange}, history[profileIndex:]...)...)
} else {
history = append(history, baseChange)
}
change := cloneChange(record.change)
change.Previous = []Origin{cloneOrigin(displaced.origin)}
if profileIndex >= 0 {
// The effective-input projection created a leaf-shaped profile
// record. Replace it with the actual parent operation.
profileIndex++
history[profileIndex] = change
} else {
history = append(history, change)
}
metadata.history[pointer] = history
}
}
}
func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) { func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) {
pointer := path.String() pointer := path.String()
_, explicit := decoded.presence[pointer] _, explicit := decoded.presence[pointer]

View File

@@ -224,7 +224,6 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid}, {name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
{name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable}, {name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable},
{name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid}, {name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid},
{name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable},
{name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid}, {name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
{name: "overrides", source: validSource(), options: []Options[config]{{Overrides: []Override{{Path: Keys("name"), Value: "other", Flag: "--name"}}}}, code: CodeOptionsUnavailable}, {name: "overrides", source: validSource(), options: []Options[config]{{Overrides: []Override{{Path: Keys("name"), Value: "other", Flag: "--name"}}}}, code: CodeOptionsUnavailable},
{name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid}, {name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid},

165
overlay.go Normal file
View File

@@ -0,0 +1,165 @@
package configkit
import (
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type overlayRecord struct {
change Change
displaced []displacedDefinition
}
type displacedDefinition struct {
path Path
origin Origin
}
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
return nil, []Diagnostic{{
Code: CodeProfileInvalid, Severity: SeverityError,
Message: "profile overlay requires mapping document roots",
}}
}
result := &composedDocument{
root: document.Clone(base.root),
claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins),
overlays: cloneOverlayRecords(base.overlays),
inputBytes: base.inputBytes,
inputNodes: base.inputNodes,
}
mergeOverlayOrigin(result.origins, Path{}, overlay.Location)
mergeOverlayMappings(result, result.root, overlay, Path{})
return result, nil
}
func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path) {
for _, incoming := range overlay.Mapping {
path := current.Key(incoming.Key)
index := documentEntryIndex(target.Mapping, incoming.Key)
if index < 0 {
cloned := incoming
cloned.Value = document.Clone(incoming.Value)
target.Mapping = append(target.Mapping, cloned)
collectOverlayOrigins(result.origins, incoming.Value, path)
continue
}
existing := target.Mapping[index].Value
if existing.Kind == document.Mapping && incoming.Value.Kind == document.Mapping {
mergeOverlayOrigin(result.origins, path, incoming.Value.Location)
mergeOverlayMappings(result, existing, incoming.Value, path)
continue
}
displaced := displacedOrigins(existing, result.origins, path)
previous := cloneOrigin(result.origins[path.String()])
deleteOriginSubtree(result.origins, path)
target.Mapping[index] = incoming
target.Mapping[index].Value = document.Clone(incoming.Value)
collectOverlayOrigins(result.origins, incoming.Value, path)
result.overlays = append(result.overlays, overlayRecord{
change: Change{
Path: path.clone(), Origin: profileOrigin(incoming.Value.Location),
Previous: previousOrigins(previous),
},
displaced: displaced,
})
}
}
func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path) {
origins[path.String()] = profileOrigin(node.Location)
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectOverlayOrigins(origins, entry.Value, path.Key(entry.Key))
}
case document.Sequence:
for index, child := range node.Sequence {
collectOverlayOrigins(origins, child, path.Index(index))
}
}
}
func mergeOverlayOrigin(origins map[string]Origin, path Path, location document.Location) {
pointer := path.String()
origin := cloneOrigin(origins[pointer])
origin.Layer = LayerProfile
origin.Locations = append(origin.Locations, publicLocation(location))
origins[pointer] = origin
}
func profileOrigin(location document.Location) Origin {
return Origin{Layer: LayerProfile, Locations: []Location{publicLocation(location)}}
}
func previousOrigins(origin Origin) []Origin {
if origin.Layer == "" {
return nil
}
return []Origin{origin}
}
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
var displaced []displacedDefinition
collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
})
return displaced
}
func collectDisplacedOrigins(displaced *[]displacedDefinition, node *document.Node, origins map[string]Origin, path Path, inherited Origin) {
origin, ok := origins[path.String()]
if !ok {
origin = cloneOrigin(inherited)
origin.Locations = []Location{publicLocation(node.Location)}
}
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origin)})
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectDisplacedOrigins(displaced, entry.Value, origins, path.Key(entry.Key), origin)
}
case document.Sequence:
for index, child := range node.Sequence {
collectDisplacedOrigins(displaced, child, origins, path.Index(index), origin)
}
}
}
func deleteOriginSubtree(origins map[string]Origin, path Path) {
prefix := path.String()
for pointer := range origins {
if pointer == prefix || strings.HasPrefix(pointer, prefix+"/") {
delete(origins, pointer)
}
}
}
func cloneOriginIndex(origins map[string]Origin) map[string]Origin {
cloned := make(map[string]Origin, len(origins))
for pointer, origin := range origins {
cloned[pointer] = cloneOrigin(origin)
}
return cloned
}
func cloneOverlayRecords(records []overlayRecord) []overlayRecord {
cloned := make([]overlayRecord, len(records))
for index, record := range records {
cloned[index].change = cloneChange(record.change)
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
for displacedIndex, displaced := range record.displaced {
cloned[index].displaced[displacedIndex] = displacedDefinition{
path: displaced.path.clone(), origin: cloneOrigin(displaced.origin),
}
}
}
return cloned
}

78
overlay_test.go Normal file
View File

@@ -0,0 +1,78 @@
package configkit
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees(t *testing.T) {
baseRoot := parseCompositionDocument(t, "source-1", `shared:
kept: base
changed: old
sequence: [one, two]
nullable: value
to_map: scalar
to_scalar: {child: old}
`)
base, diagnostics := composeDocuments(baseRoot)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
overlay := parseCompositionDocument(t, "source-2", `shared:
changed: new
added: profile
sequence: []
nullable: null
to_map: {child: new}
to_scalar: scalar
`)
baseBefore := document.Clone(base.root)
overlayBefore := document.Clone(overlay)
result, diagnostics := overlayDocuments(base, overlay)
if len(diagnostics) != 0 {
t.Fatalf("overlayDocuments() diagnostics = %#v", diagnostics)
}
checks := []struct {
path Path
kind document.Kind
text string
}{
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
{path: Keys("shared", "added"), kind: document.String, text: "profile"},
{path: Keys("sequence"), kind: document.Sequence},
{path: Keys("nullable"), kind: document.Null},
{path: Keys("to_map"), kind: document.Mapping},
{path: Keys("to_map", "child"), kind: document.String, text: "new"},
{path: Keys("to_scalar"), kind: document.String, text: "scalar"},
}
for _, check := range checks {
node, ok := documentAtPath(result.root, check.path)
if !ok || node.Kind != check.kind || node.Text != check.text {
t.Errorf("node %s = %#v, %v", check.path.String(), node, ok)
}
}
if _, exists := result.origins["/sequence/0"]; exists {
t.Fatal("replaced empty sequence retained a stale index origin")
}
if _, exists := result.origins["/to_scalar/child"]; exists {
t.Fatal("parent replacement retained a stale child origin")
}
shared := result.origins["/shared"]
if shared.Layer != LayerProfile || len(shared.Locations) != 2 {
t.Fatalf("shared mapping origin = %#v", shared)
}
if len(result.overlays) != 5 {
t.Fatalf("overlay records = %#v, want five replacements", result.overlays)
}
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
t.Fatal("overlay mutated an input document")
}
result.root.Mapping[0].Value.Mapping[0].Value.Text = "changed"
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
t.Fatal("overlay result aliases an input document")
}
}

179
profile.go Normal file
View File

@@ -0,0 +1,179 @@
package configkit
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
func activeProfile(name string) bool {
return name != "" && name != "default"
}
func validProfileName(name string) bool {
if !activeProfile(name) {
return true
}
for _, character := range name {
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '_' || character == '-' {
continue
}
return false
}
return true
}
func loadProfileDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic}
}
profilePath := filepath.Join(rootPath, "profiles", name+".yml")
canonical, err := canonicalExistingPath(profilePath)
if err != nil {
code := CodeProfileInvalid
message := "selected profile could not be resolved"
if errors.Is(err, fs.ErrNotExist) {
code, message = CodeProfileMissing, "selected profile does not exist"
} else if errors.Is(err, fs.ErrPermission) {
code, message = CodeImportPermission, "selected profile cannot be accessed"
}
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
}
if !containedPath(rootPath, canonical) {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile resolves outside the configuration root", nil)}
}
for _, existing := range sources {
if existing.Path != "" && existing.Path == canonical {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileRepeated, "selected profile is already present in the base source graph", nil)}
}
}
if len(sources)+1 > limits.sources {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the source-count limit", nil)}
}
root, err := os.OpenRoot(rootPath)
if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected profile", Location{})
value.Primary = nil
return nil, cloneSourceInfos(sources), []Diagnostic{value}
}
relative, err := filepath.Rel(rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
_ = root.Close()
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
}
sourceID := "source-" + strconv.Itoa(len(sources)+1)
profileLocation := &Location{SourceID: sourceID}
profileSource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("profiles", name+".yml")), Path: canonical, Layer: LayerProfile}
sources = append(cloneSourceInfos(sources), profileSource)
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
if diagnostic != nil {
_ = root.Close()
return nil, sources, []Diagnostic{*diagnostic}
}
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
if len(issues) != 0 {
_ = root.Close()
return nil, sources, importParseDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(profileRoot)
if base.inputNodes+nodeCount > limits.nodes {
_ = root.Close()
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
}
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
_ = root.Close()
return nil, sources, diagnostics
}
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
location := publicLocation(versionNode.Location)
_ = root.Close()
return nil, sources, []Diagnostic{{
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
}}
}
if err := root.Close(); err != nil {
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
}
profiled, diagnostics := overlayDocuments(base, profileRoot)
if profiled != nil {
profiled.inputBytes = base.inputBytes + int64(len(data))
profiled.inputNodes = base.inputNodes + nodeCount
}
return profiled, sources, diagnostics
}
func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
limit := min(int64(maxSourceBytes), remaining)
if limit < 0 {
limit = 0
}
file, err := root.Open(relative)
if err != nil {
value := classifySourceError(err, sourceID)
return nil, &value
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
value := classifySourceError(err, sourceID)
return nil, &value
}
if !info.Mode().IsRegular() {
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
return nil, &value
}
if info.Size() > limit {
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
return nil, &value
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil {
value := classifySourceError(err, sourceID)
return nil, &value
}
if int64(len(data)) > limit {
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
return nil, &value
}
return data, nil
}
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := current.Key(entry.Key)
if entry.Key == "_configkit" {
location := publicLocation(entry.KeyLocation)
return []Diagnostic{{
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
Message: "profiles cannot contain configuration directives", Primary: &location,
}}
}
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
return diagnostics
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
return diagnostics
}
}
}
return nil
}

267
profile_test.go Normal file
View File

@@ -0,0 +1,267 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
type profileTestConfig struct {
Settings struct {
Kept string `yaml:"kept"`
Changed string `yaml:"changed"`
Added string `yaml:"added"`
} `yaml:"settings"`
Values []string `yaml:"values"`
Nullable *string `yaml:"nullable"`
Value any `yaml:"value"`
Schema map[string]string `yaml:"schema"`
}
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
imports: [{path: base.yml}]
settings:
kept: main
changed: base
values: [base, stale]
nullable: base
value: {child: base}
`)
basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `settings:
changed: profile
values: [profile]
nullable: null
value: scalar
`)
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{
Imports: true,
Profile: "testing",
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
defaultValue := "default"
return profileTestConfig{
Values: []string{"default"}, Nullable: &defaultValue,
Settings: struct {
Kept string `yaml:"kept"`
Changed string `yaml:"changed"`
Added string `yaml:"added"`
}{Kept: "default", Changed: "default", Added: "default"},
}
}},
})
if err != nil {
t.Fatal(err)
}
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "profile" || result.Config.Settings.Added != "imported" {
t.Fatalf("settings = %#v", result.Config.Settings)
}
if !reflect.DeepEqual(result.Config.Values, []string{"profile"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" {
t.Fatalf("profile values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value)
}
sources := result.Sources()
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, profilePath)}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
}
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
changed, _ := result.Metadata().Lookup(Keys("settings", "changed"))
kept, _ := result.Metadata().Lookup(Keys("settings", "kept"))
if changed.Origin.Layer != LayerProfile || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
t.Fatalf("origins = changed %#v kept %#v", changed, kept)
}
if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok {
t.Fatal("removed child remains effective")
}
childHistory := result.Metadata().History(Keys("value", "child"))
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerProfile {
t.Fatalf("removed child history = %#v", childHistory)
}
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
t.Fatal("removed sequence index remains effective")
}
indexHistory := result.Metadata().History(Keys("values").Index(1))
if len(indexHistory) < 2 || indexHistory[len(indexHistory)-1].Path.String() != "/values" {
t.Fatalf("removed sequence history = %#v", indexHistory)
}
}
func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
for _, name := range []string{"", "default"} {
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
}
}
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
t.Fatalf("selected profile = %#v, %v", selected, err)
}
for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} {
t.Run(name, func(t *testing.T) {
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: name}, CodeOptionsInvalid)
})
}
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "missing"}, CodeProfileMissing)
writeImportFile(t, directory, "profiles/fallback.yaml", "settings: {changed: fallback}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "fallback"}, CodeProfileMissing)
}
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
if !version.Present || version.Text != "v1" {
return errors.New("unexpected version")
}
return nil
}}
for _, test := range []struct {
name string
profile string
code Code
}{
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
})
}
t.Run("mapping sibling is allowed", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\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}})
if err != nil || result.Config.Schema["other"] != "allowed" {
t.Fatalf("mapping sibling = %#v, %v", result, err)
}
})
t.Run("profile aliases imported base", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
})
}
func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
t.Run("base parse precedes profile", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: profile}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
})
t.Run("profile parse", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: [\n")
loadError := requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
t.Fatalf("profile parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
}
})
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
t.Run("symlink escape", func(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideProfile := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
if err := os.Mkdir(filepath.Join(root, "profiles"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideProfile, filepath.Join(root, "profiles/testing.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, root, "main.yml", "{}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeProfileOutsideRoot)
})
}
t.Run("byte source context", func(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
})
if err != nil || result.Config.Settings.Changed != "selected" {
t.Fatalf("byte profile = %#v, %v", result, err)
}
requireProfileLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[profileTestConfig]{Profile: "testing"}, CodeOptionsInvalid)
})
}
func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
for _, test := range []struct {
name string
limits func(parsedSource, int) importLimits
}{
{name: "source count", limits: func(_ parsedSource, _ int) importLimits {
return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes}
}},
{name: "aggregate bytes", limits: func(main parsedSource, profileBytes int) importLimits {
return importLimits{sources: 2, bytes: main.bytes + int64(profileBytes) - 1, nodes: maxDocumentNodes}
}},
{name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits {
return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}
}},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
profileData := "settings: {changed: selected}\n"
writeImportFile(t, directory, "profiles/testing.yml", profileData)
main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base, diagnostics := composeDocuments(main.document)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base.inputBytes = main.bytes
base.inputNodes = countInputNodes(main.document)
_, _, diagnostics = loadProfileDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(profileData)))
if len(diagnostics) == 0 || diagnostics[0].Code != CodeProfileLimitExceeded {
t.Fatalf("diagnostics = %#v", diagnostics)
}
})
}
}
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
t.Helper()
result, err := Load[profileTestConfig](source, options)
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, code)
}
return loadError
}

View File

@@ -87,6 +87,14 @@ const (
CodeImportLimitExceeded Code = "import.limit_exceeded" CodeImportLimitExceeded Code = "import.limit_exceeded"
CodeImportVersion Code = "import.version" 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" CodeComposeInvalid Code = "compose.invalid"
CodeComposeConflict Code = "compose.conflict" CodeComposeConflict Code = "compose.conflict"