From da02a19f2cdf39585bf629ecbe9758c0f31f94ac Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 12 Sep 2026 16:04:31 +0000 Subject: [PATCH] Bind engine secret handling to facade types --- docs/roadmap/implementation.md | 24 +++++ internal/engine/callback_ownership_test.go | 4 +- internal/engine/clone.go | 6 +- internal/engine/decode.go | 2 +- internal/engine/explain_test.go | 16 +-- internal/engine/glob_test.go | 18 ++-- internal/engine/imports_test.go | 14 +-- internal/engine/independence_test.go | 6 +- internal/engine/load.go | 8 +- internal/engine/load_test.go | 60 ++++++------ internal/engine/metadata_test.go | 6 +- internal/engine/override_test.go | 16 +-- internal/engine/profile_test.go | 12 +-- internal/engine/projection.go | 2 +- internal/engine/projection_test.go | 4 +- internal/engine/provenance_test.go | 18 ++-- internal/engine/render_test.go | 8 +- internal/engine/resolution_test.go | 26 ++--- internal/engine/schema.go | 42 ++++++-- internal/engine/schema_test.go | 22 ++--- internal/engine/secret_binding_test.go | 107 +++++++++++++++++++++ internal/engine/secret_reference.go | 91 +++++------------- internal/engine/secret_reference_test.go | 38 ++++---- internal/engine/source_fifo_test.go | 2 +- internal/engine/validation.go | 2 +- internal/engine/validation_test.go | 16 +-- internal/engine/value_operation.go | 4 +- internal/engine/version_path_test.go | 6 +- 28 files changed, 348 insertions(+), 232 deletions(-) create mode 100644 internal/engine/secret_binding_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9d8915d..96a63ae 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -276,6 +276,30 @@ tests pass; binding instances cannot affect each other. Run `go test ./internal/engine -run 'Secret|Schema|Decode|Clone|Projection|Validation|Callback'`, `go test -race ./internal/engine`, and the everyday check. +### Completion + +Status: complete (2026-09-12). + +`internal/engine` now receives a per-operation `SecretBinding` at schema and +load construction. Its exact `reflect.Type`, `Read`, and `Make` functions carry +the facade-owned `SecretState` through decoding, projection, unresolved and +validated cloning, dynamic-value rejection, secret lookup, resolution helpers, +and validator mutation auditing. The binding is retained by each schema only; +there is no package-global registration or engine-owned replacement secret. + +Removed the candidate's production `Secret` and `SecretEnv` declarations. +`secret_binding_test.go` provides a local test secret type, fresh binding for +every test load, and exact-type/isolation coverage using a second local secret +type. Existing secret tests continue to cover defaults, pointers and containers, +resolution-selected names, lookup-once behavior, borrowed callback values, +redaction, and validator replacement without credential disclosure. The root +implementation remains unchanged for this migration handoff. + +Linux validation with Go 1.26.7 passed the focused secret/schema/decode/clone/ +projection/validation/callback suite, `go test -race ./internal/engine`, and +`./scripts/check.sh`. There were no deviations. The next handoff creates the +temporary facade's root-owned value, source, diagnostic, and secret wrappers. + ## Stage 4 — Build candidate value, source, and diagnostic wrappers Status: not started. diff --git a/internal/engine/callback_ownership_test.go b/internal/engine/callback_ownership_test.go index ede2318..190b669 100644 --- a/internal/engine/callback_ownership_test.go +++ b/internal/engine/callback_ownership_test.go @@ -30,11 +30,11 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) { } calls := 0 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 { t.Fatal(err) } - second, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition}) + second, err := testLoadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition}) if err != nil { t.Fatal(err) } diff --git a/internal/engine/clone.go b/internal/engine/clone.go index e8a9828..4a3a238 100644 --- a/internal/engine/clone.go +++ b/internal/engine/clone.go @@ -128,11 +128,11 @@ func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor, return result, nil case descriptorSecret: result := reflect.New(value.Type()).Elem() - reference := value.Interface().(Secret) + reference := o.schema.secrets.Read(value) if o.cloneMode == cloneValidatedSecrets { result.Set(value) - } else if reference.specified { - result.Set(reflect.ValueOf(SecretEnv(reference.EnvName()))) + } else if reference.Specified { + result.Set(o.schema.secrets.Make(SecretState{Name: reference.Name, Specified: true})) } return result, nil case descriptorText: diff --git a/internal/engine/decode.go b/internal/engine/decode.go index cd61475..43f1f48 100644 --- a/internal/engine/decode.go +++ b/internal/engine/decode.go @@ -179,7 +179,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri if node.Kind != document.String { 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: if node.Kind != document.String { return typeMismatch(path, node, "text string") diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go index d155d4f..18f0fb1 100644 --- a/internal/engine/explain_test.go +++ b/internal/engine/explain_test.go @@ -26,7 +26,7 @@ func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) { 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, 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", Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency { value := explanationConcurrency{} @@ -84,7 +84,7 @@ items: [profile] labels: {profile: retained} 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" { t.Fatalf("secret lookup = %q", name) } @@ -285,7 +285,7 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) { type config struct { 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 { t.Fatal(err) } @@ -343,7 +343,7 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) { writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n") opts.Profile = "p" } - result, err := Load(File(main), opts) + result, err := testLoad(File(main), opts) if err != nil { t.Fatal(err) } @@ -380,11 +380,11 @@ func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing. Defaults: func() config { return config{Version: "4"} }, 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 { 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 { t.Fatal(err) } @@ -449,7 +449,7 @@ func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) { Version string `yaml:"version"` } 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++ return "CREDENTIAL_CANARY", true }, Options[config]{Definition: Definition[config]{ @@ -476,7 +476,7 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) } for _, overlap := range []bool{false, true} { 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)} delete(c.M, "old") r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old")) diff --git a/internal/engine/glob_test.go b/internal/engine/glob_test.go index d575536..54e88cb 100644 --- a/internal/engine/glob_test.go +++ b/internal/engine/glob_test.go @@ -26,7 +26,7 @@ items: {alpha: alpha} var previous []SourceInfo 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 { 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/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 { t.Fatal(err) } @@ -79,7 +79,7 @@ items: {root: root} ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n") _ = ignoredPath 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" { t.Fatalf("implicit discovery result = %#v, %v", plain, err) } @@ -116,7 +116,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) { t.Fatal(err) } 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 { t.Fatalf("optional unmatched glob = %#v, %v", result, err) } @@ -166,7 +166,7 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) { t.Skipf("symlinks unavailable: %v", err) } 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" { t.Fatalf("contained directory symlink = %#v, %v", result, err) } @@ -232,7 +232,7 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) { func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) { directory := t.TempDir() 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}, }) 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) 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" { t.Fatalf("byte filesystem context = %#v, %v", result, err) } 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 { 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" { selectedLink := filepath.Join(directory, "selected-link.yml") 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) { t.Fatalf("selected symlink identity = %#v, %v", linked, err) } diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index 973c24e..7630103 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -33,7 +33,7 @@ func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) { for _, imports := range []bool{false, true} { directory := t.TempDir() 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 { t.Fatalf("Imports=%v: %v", imports, err) } @@ -61,7 +61,7 @@ settings: thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\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 { t.Fatal(err) } @@ -163,7 +163,7 @@ func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) { optional: true 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 { t.Fatalf("optional result = %#v, %v", result, err) } @@ -253,7 +253,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) { t.Skipf("symlinks unavailable: %v", err) } 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) { 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))) }}, } { 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" { t.Fatalf("explicit filesystem result = %#v, %v", result, err) } @@ -318,7 +318,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) { root := t.TempDir() mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\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}, }) 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 { t.Helper() - result, err := Load[importTestConfig](source, options) + result, err := testLoad[importTestConfig](source, options) if result != nil || err == nil { t.Fatalf("result, error = %#v, %v", result, err) } diff --git a/internal/engine/independence_test.go b/internal/engine/independence_test.go index 5da002c..65e9357 100644 --- a/internal/engine/independence_test.go +++ b/internal/engine/independence_test.go @@ -46,14 +46,14 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) { go func(index int) { defer group.Done() 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) } group.Wait() for index, err := range errorsByIndex { 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" @@ -88,7 +88,7 @@ func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) { go func(candidate Source) { defer group.Done() <-start - _, err := Load[config](candidate) + _, err := testLoad[config](candidate) errorsFound <- err }(candidate) } diff --git a/internal/engine/load.go b/internal/engine/load.go index 545254b..87d751a 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -110,11 +110,11 @@ func (r *Result[T]) Diagnostics() []Diagnostic { // Load parses and strictly decodes zero or one selected source, explicitly enabled // imports, and one selected profile. It accepts at most one Options value and // returns no result when any error is reported. -func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { - return loadWithSecretLookup(source, os.LookupEnv, supplied...) +func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Result[T], error) { + 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]) (*Result[T], error) { accumulator := diagnosticAccumulator{} options, diagnostics := selectOptions(source, supplied) accumulator.add(diagnostics...) @@ -128,7 +128,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied .. if len(diagnostics) != 0 { return nil, accumulator.loadError() } - schema, schemaIssue := buildSchema(reflect.TypeFor[T]()) + schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets) if schemaIssue != nil { accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) return nil, accumulator.loadError() diff --git a/internal/engine/load_test.go b/internal/engine/load_test.go index bf0f787..9e7c51e 100644 --- a/internal/engine/load_test.go +++ b/internal/engine/load_test.go @@ -38,7 +38,7 @@ func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { 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 { defaultsCalls.Add(1) return config{Name: "default"} @@ -92,7 +92,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) { validation.Warning(versionPath, "review schema compatibility", Keys("missing_related")) 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 result != nil || err == nil { t.Fatalf("result, err = %#v, %v", result, err) @@ -144,21 +144,21 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) { load func(Source, *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 { return "", err } return result.Config.Version, nil }}, {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 { return "", err } return result.Config.Version, nil }}, {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 { return "", err } @@ -168,7 +168,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) { return *result.Config.Version, nil }}, {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 { 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) } else { var loadError *LoadError @@ -207,7 +207,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) { Label string `yaml:"label"` } 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) } else { var loadError *LoadError @@ -240,7 +240,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) { checked = version 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 { t.Fatal(err) } @@ -266,7 +266,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) { called = true 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 { t.Fatalf("result = %#v, checker called = %v", result, called) } @@ -289,7 +289,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) { } 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 { t.Fatal(err) } @@ -299,7 +299,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) { type sequenceConfig struct { 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" { t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err) } @@ -311,13 +311,13 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) { if err != nil { 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" { t.Fatalf("escaped configuration = %#v, %v", escapedResult, err) } 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) } else { requireDiagnostic(t, err, CodeDecodeType, "/versions/other") @@ -332,16 +332,16 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) { type config struct { 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 { 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 { t.Fatalf("range result = %#v", result) } 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 { t.Fatalf("quoted integer result = %#v", result) } @@ -352,11 +352,11 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) { type config struct { 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) { 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 { t.Fatalf("range result = %#v", result) } @@ -371,21 +371,21 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) { type config struct { 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 }}, {name: "duration", load: func() error { type config struct { 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 }}, {name: "secret", load: func() error { type config struct { 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 }}, } { @@ -409,7 +409,7 @@ func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) { validation.Error(Keys("version"), "unsupported configuration version") 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{} }, Version: rule, Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil }, @@ -440,7 +440,7 @@ func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) { Name string `yaml:"name"` } 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 { defaultsCalls.Add(1) return config{Name: "default"} @@ -474,7 +474,7 @@ func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) { Nested nested `yaml:"nested"` 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 { 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 { 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 { 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 { t.Fatalf("result, err = %#v, %v", result, err) } @@ -577,9 +577,9 @@ func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) { run func() (any, error) code Code }{ - {name: "source", run: func() (any, error) { return Load[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: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField}, + {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 testLoad[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType}, + {name: "decode", run: func() (any, error) { return testLoad[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/engine/metadata_test.go b/internal/engine/metadata_test.go index 013a239..a615cb3 100644 --- a/internal/engine/metadata_test.go +++ b/internal/engine/metadata_test.go @@ -8,7 +8,7 @@ import ( func TestMetadataHistoricalPaths(t *testing.T) { 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{ {Path: Keys("retired"), Value: nil, Flag: "--retired"}, {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, "second.yml", "definitions: {recap: {output: recap.md}}\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 { t.Fatal(err) } @@ -232,7 +232,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(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 { t.Fatal(err) } diff --git a/internal/engine/override_test.go b/internal/engine/override_test.go index 9ea6dd7..3f519b2 100644 --- a/internal/engine/override_test.go +++ b/internal/engine/override_test.go @@ -56,7 +56,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te {Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"}, {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" { t.Fatalf("looked up superseded secret name %q", name) } @@ -83,7 +83,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te }, }) if err != nil { - t.Fatalf("loadWithSecretLookup() error = %v", err) + t.Fatalf("testLoadWithSecretLookup() error = %v", err) } want := overrideLifecycleSettings{ 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, "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 { 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}, } { 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 { t.Fatalf("result = %#v, want nil", result) } @@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) { type config struct { 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 }}}, 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 { 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", }}}) if result != nil { @@ -249,7 +249,7 @@ func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) { type config struct { 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", }}}) loadError := requireSecretLoadCode(t, err, CodeDecodeType) @@ -262,7 +262,7 @@ func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) { type config struct { 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 { t.Fatal(err) } diff --git a/internal/engine/profile_test.go b/internal/engine/profile_test.go index 90935e5..5659ae6 100644 --- a/internal/engine/profile_test.go +++ b/internal/engine/profile_test.go @@ -40,7 +40,7 @@ nullable: null value: scalar `) - result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{ + result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{ Imports: true, Profile: "testing", 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") 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 { 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 { t.Fatalf("selected profile = %#v, %v", selected, err) } @@ -150,7 +150,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) { directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\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" { 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) { 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]{ + result, err := testLoad[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" { @@ -251,7 +251,7 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) { func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError { t.Helper() - result, err := Load[profileTestConfig](source, options) + result, err := testLoad[profileTestConfig](source, options) if result != nil || err == nil { t.Fatalf("result, error = %#v, %v", result, err) } diff --git a/internal/engine/projection.go b/internal/engine/projection.go index 1c3a7c7..e759de2 100644 --- a/internal/engine/projection.go +++ b/internal/engine/projection.go @@ -199,7 +199,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor case descriptorSecret: node, issue := o.node(document.String, path, depth) if issue == nil { - node.Text = value.Interface().(Secret).EnvName() + node.Text = o.schema.secrets.Read(value).Name } return node, issue case descriptorText: diff --git a/internal/engine/projection_test.go b/internal/engine/projection_test.go index a38782f..3009aa5 100644 --- a/internal/engine/projection_test.go +++ b/internal/engine/projection_test.go @@ -359,9 +359,9 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) { func mustSchemaFor[T any](t *testing.T) *schemaDescriptor { t.Helper() - schema, issue := buildSchema(reflect.TypeFor[T]()) + schema, issue := testBuildSchema(reflect.TypeFor[T]()) if issue != nil { - t.Fatalf("buildSchema() issue = %#v", issue) + t.Fatalf("testBuildSchema() issue = %#v", issue) } return schema } diff --git a/internal/engine/provenance_test.go b/internal/engine/provenance_test.go index 18412fa..d6a40c0 100644 --- a/internal/engine/provenance_test.go +++ b/internal/engine/provenance_test.go @@ -70,7 +70,7 @@ empty: {} }, } - result, err := Load[layeredConfig](File(mainPath), Options[layeredConfig]{ + result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{ Imports: true, Profile: "testing", Definition: definition, }) if err != nil { @@ -134,7 +134,7 @@ empty: {} t.Fatalf("canonical resolution history = %#v", lastValue) } - schema, issue := buildSchema(reflect.TypeFor[layeredConfig]()) + schema, issue := testBuildSchema(reflect.TypeFor[layeredConfig]()) if issue != nil { t.Fatal(issue) } @@ -173,7 +173,7 @@ func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) { group.Add(1) go func() { defer group.Done() - result, err := Load[layeredConfig](File(mainPath), options) + result, err := testLoad[layeredConfig](File(mainPath), options) if err != nil { errors <- err return @@ -236,7 +236,7 @@ composite: {first: first} CLIEqual string `yaml:"cli_equal"` } var callbackErr error - result, err := Load[config](File(mainPath), Options[config]{ + 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) { @@ -322,7 +322,7 @@ func TestResolutionInputUsesEntryShapesAcrossTypedAndDynamicValues(t *testing.T) Dynamic any `yaml:"dynamic"` } var callbackErr error - _, err := Load[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 { + _, 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 { @@ -371,7 +371,7 @@ func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T) } { t.Run(test.name, func(t *testing.T) { var callbackErr error - _, err := Load[config](test.source(), Options[config]{Imports: true, Filesystem: context, Definition: Definition[config]{ + _, 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")) @@ -398,7 +398,7 @@ func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T) } var callbackErr error - _, err := Load[config](Bytes("memory://standalone.yml", []byte("root: memory\n")), Options[config]{Definition: Definition[config]{ + _, 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")) @@ -426,7 +426,7 @@ func TestResolutionInputRemainsFixedAcrossRecordedMutations(t *testing.T) { Values map[string]string `yaml:"values"` } var callbackErr error - result, err := Load[config](Bytes("mutations.yml", []byte("existing: original\nvalues: {original: move}\n")), Options[config]{Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) 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")) @@ -475,7 +475,7 @@ func TestResolutionInputQueriesRemainIndependentAcrossLoads(t *testing.T) { group.Add(1) go func() { defer group.Done() - _, err := Load[config](Bytes("parallel.yml", []byte("value: stable\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { + _, 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" { diff --git a/internal/engine/render_test.go b/internal/engine/render_test.go index 560eed9..9c041e7 100644 --- a/internal/engine/render_test.go +++ b/internal/engine/render_test.go @@ -85,7 +85,7 @@ sequence: [second, first] 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 { t.Fatal(err) } @@ -160,7 +160,7 @@ func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { 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 { 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) return nil } - result, err := Load(NoSource(), Options[cfg]{Definition: definition}) + result, err := testLoad(NoSource(), Options[cfg]{Definition: definition}) if err != nil { t.Fatal(err) } @@ -234,7 +234,7 @@ func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T type cfg struct { 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)} }}}) var failure *LoadError diff --git a/internal/engine/resolution_test.go b/internal/engine/resolution_test.go index 28f3210..84c1e71 100644 --- a/internal/engine/resolution_test.go +++ b/internal/engine/resolution_test.go @@ -41,7 +41,7 @@ func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) { }, } 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 { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t Absent int `yaml:"absent"` } 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} }, Resolve: func(configuration *config, resolution *Resolution) error { 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"` 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} }, Resolve: func(configuration *config, resolution *Resolution) error { Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other")) @@ -157,7 +157,7 @@ func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t type config struct { 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 { delete(configuration.Items, "remove") resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries") @@ -188,7 +188,7 @@ func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) { type config struct { 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 { Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values") return nil @@ -237,7 +237,7 @@ func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) { } for _, test := range tests { 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) }) } @@ -269,7 +269,7 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t } for _, test := range tests { 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) for _, diagnostic := range loadError.Diagnostics() { if strings.Contains(diagnostic.Message, "CANARY") { @@ -291,7 +291,7 @@ func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) { filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n") var savedField FieldInfo var savedSource SourceInfo - result, err := Load[config](File(filename), Options[config]{Definition: Definition[config]{ + 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")} { @@ -339,7 +339,7 @@ func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) { t.Fatal("saved resolution records alias a completed result") } - noSource, err := Load[config](NoSource(), Options[config]{Definition: Definition[config]{ + 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")) @@ -364,7 +364,7 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) { Value int `yaml:"value"` } 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 return nil }}}); err != nil { @@ -397,7 +397,7 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) { requireClosed(t, func() { (&Resolution{}).Source("source-1") }) var successful *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 { successful = resolution return nil }}}); err != nil { @@ -407,7 +407,7 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) { requireClosed(t, func() { successful.Source("source-1") }) var failed *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 { failed = resolution return errors.New("resolver failed") }}}); err == nil { @@ -424,7 +424,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) { Version string `yaml:"version"` } 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 { retained = validation return nil diff --git a/internal/engine/schema.go b/internal/engine/schema.go index abfc595..d9bdc84 100644 --- a/internal/engine/schema.go +++ b/internal/engine/schema.go @@ -29,8 +29,9 @@ const ( ) type schemaDescriptor struct { - root *typeDescriptor - types map[reflect.Type]*typeDescriptor + root *typeDescriptor + types map[reflect.Type]*typeDescriptor + secrets SecretBinding } type typeDescriptor struct { @@ -65,20 +66,39 @@ type schemaIssue struct { 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 ( durationType = reflect.TypeFor[time.Duration]() - secretType = reflect.TypeFor[Secret]() textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() 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)) { return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"} } builder := schemaBuilder{ cache: make(map[reflect.Type]*typeDescriptor), building: make(map[reflect.Type]bool), + secrets: secrets, } descriptor, issue := builder.describe(root, Path{}) if issue != nil { @@ -87,12 +107,13 @@ func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) { 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 &schemaDescriptor{root: descriptor, types: builder.cache}, nil + return &schemaDescriptor{root: descriptor, types: builder.cache, secrets: secrets}, nil } type schemaBuilder struct { cache map[reflect.Type]*typeDescriptor building map[reflect.Type]bool + secrets SecretBinding } 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 return descriptor, nil } - if current == secretType { + if b.secrets.Type != nil && current == b.secrets.Type { descriptor.kind = descriptorSecret return descriptor, nil } @@ -189,6 +210,15 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript 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 { for i := 0; i < current.NumField(); i++ { field := current.Field(i) diff --git a/internal/engine/schema_test.go b/internal/engine/schema_test.go index 7b16c95..e784d7a 100644 --- a/internal/engine/schema_test.go +++ b/internal/engine/schema_test.go @@ -68,9 +68,9 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) { private chan int } - schema, issue := buildSchema(reflect.TypeFor[config]()) + schema, issue := testBuildSchema(reflect.TypeFor[config]()) if issue != nil { - t.Fatalf("buildSchema() issue = %#v", issue) + t.Fatalf("testBuildSchema() issue = %#v", issue) } root := schema.root checks := map[string]descriptorKind{ @@ -105,7 +105,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) { if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap { 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) } if _, exists := root.fieldByName["ignored"]; exists { @@ -116,7 +116,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) { func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) { t.Parallel() - first, issue := buildSchema(reflect.TypeFor[recursiveConfig]()) + first, issue := testBuildSchema(reflect.TypeFor[recursiveConfig]()) if issue != nil { t.Fatal(issue.message) } @@ -128,7 +128,7 @@ func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T if children.kind != descriptorMap || children.element != first.root { 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 { t.Fatal(issue.message) } @@ -207,7 +207,7 @@ func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) { } for _, test := range tests { 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) } }) @@ -221,7 +221,7 @@ func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) { Value panicText `yaml:"value"` Structural yamlHookStruct `yaml:"structural"` } - if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil { + if _, issue := testBuildSchema(reflect.TypeFor[config]()); issue != nil { t.Fatal(issue.message) } } @@ -261,7 +261,7 @@ func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) { Dynamic any `yaml:"dynamic"` Extras map[string]string `yaml:",inline"` } - schema, issue := buildSchema(reflect.TypeFor[config]()) + schema, issue := testBuildSchema(reflect.TypeFor[config]()) if issue != nil { t.Fatal(issue.message) } @@ -310,7 +310,7 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) { } for _, nonNil := range []bool{false, true} { 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 if nonNil { m := map[string]int{"value": 1} @@ -330,13 +330,13 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) { type recursive struct { 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") } type double struct { 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") } } diff --git a/internal/engine/secret_binding_test.go b/internal/engine/secret_binding_test.go new file mode 100644 index 0000000..b85f471 --- /dev/null +++ b/internal/engine/secret_binding_test.go @@ -0,0 +1,107 @@ +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]) (*Result[T], error) { + return Load(source, testSecretBinding(), supplied...) +} + +func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { + return loadWithSecretLookup(source, testSecretBinding(), lookup, supplied...) +} + +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) + } +} diff --git a/internal/engine/secret_reference.go b/internal/engine/secret_reference.go index b1a2705..0620dbb 100644 --- a/internal/engine/secret_reference.go +++ b/internal/engine/secret_reference.go @@ -2,60 +2,12 @@ package engine import ( "cmp" - "encoding/json" - "fmt" - "io" "reflect" "slices" - "strconv" "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 secretLookupResult struct { @@ -80,28 +32,28 @@ func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input if lookup == nil { 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) 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 value.IsNil() { 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 } switch descriptor.kind { case descriptorSecret: - reference := value.Interface().(Secret) - name := reference.EnvName() + reference := secrets.Read(value) + name := reference.Name if name == "" { code := CodeSecretRequired message := "required secret reference is absent" - if optional || reference.specified { + if optional || reference.Specified { code = CodeSecretInvalidName 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)) 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 case descriptorStruct: for _, member := range descriptor.members { @@ -133,7 +85,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa if !member.inline { 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: if value.IsNil() { @@ -144,7 +96,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa for _, key := range keys { entry := reflect.New(value.Type().Elem()).Elem() 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) } case descriptorSlice, descriptorArray: @@ -152,7 +104,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa return } 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 } -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 - 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()) }) 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 !value.IsNil() { - collectChangedSecretStates(value.Elem(), descriptor.element, path, lookups, changed) + collectChangedSecretStates(value.Elem(), descriptor.element, secrets, path, lookups, changed) } return } switch descriptor.kind { case descriptorSecret: - reference := value.Interface().(Secret) - expected, ok := lookups[reference.EnvName()] - if ok && (!expected.present || expected.value == "" || !reference.resolved || reference.value != expected.value) { + reference := secrets.Read(value) + expected, ok := lookups[reference.Name] + if ok && (!expected.present || expected.value == "" || !reference.Resolved || reference.Value != expected.value) { *changed = append(*changed, path.clone()) } case descriptorStruct: @@ -192,7 +147,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, if !member.inline { 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: if value.IsNil() { @@ -201,14 +156,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, keys := value.MapKeys() slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) }) 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: if descriptor.kind == descriptorSlice && value.IsNil() { return } 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) } } } diff --git a/internal/engine/secret_reference_test.go b/internal/engine/secret_reference_test.go index d883306..12bc9fd 100644 --- a/internal/engine/secret_reference_test.go +++ b/internal/engine/secret_reference_test.go @@ -51,7 +51,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET] return value, present } 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) { validationCalled = true if configuration.Required.Value() != "shared-value" || !configuration.Required.IsResolved() || @@ -62,7 +62,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET] }}, }) 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 { t.Fatalf("resolved config = %#v", result.Config) @@ -94,7 +94,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) { if lookup == nil { 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) diagnostic := loadError.Diagnostics()[0] if diagnostic.Path.String() != "/required" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") { @@ -107,9 +107,9 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) { type config struct { 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) - 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 { t.Fatalf("nil optional result = %#v, %v", result, err) } @@ -120,7 +120,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) { Secrets []Secret `yaml:"secrets"` } 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++ return "", false }, Options[config]{}) @@ -138,7 +138,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) { } lookedUp := []string{} 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) return "resolved-value", true }, Options[config]{Definition: Definition[config]{ @@ -151,7 +151,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) { }, }}) 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" { t.Fatalf("lookups = %#v, validated = %q, config = %#v", lookedUp, validated, result.Config) @@ -172,7 +172,7 @@ func TestResolutionInputQueriesDoNotReadOrResolveSecrets(t *testing.T) { reader := &resolutionQueryReader{input: strings.NewReader("value: source\nsecret: QUERY_SECRET\n")} lookups := 0 var callbackErr error - result, err := loadWithSecretLookup(Reader("query.yml", reader), func(name string) (string, bool) { + result, err := testLoadWithSecretLookup(Reader("query.yml", reader), func(name string) (string, bool) { lookups++ if name != "QUERY_SECRET" { t.Fatalf("secret lookup name = %q", name) @@ -210,7 +210,7 @@ func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) { type config struct { 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 }, Options[config]{Definition: Definition[config]{Validate: func(configuration *config, validation *Validation) { configuration.Secret = SecretEnv(configuration.Secret.EnvName()) @@ -225,13 +225,13 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) { type config struct { 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")} }}, }) if err != nil { 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 }}, }) if err != nil { @@ -243,14 +243,14 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) { t.Setenv("CONFIGKIT_RELOOKUP_SECRET", "one") 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 { t.Fatal(err) } if err := os.Setenv("CONFIGKIT_RELOOKUP_SECRET", "two"); err != nil { t.Fatal(err) } - realSecond, err := Load(NoSource(), options) + realSecond, err := testLoad(NoSource(), options) if err != nil { t.Fatal(err) } @@ -276,7 +276,7 @@ func TestSecretSafeFormattingJSONProjectionAndHistory(t *testing.T) { type config struct { 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 }, Options[config]{}) if err != nil { @@ -317,7 +317,7 @@ func TestBootstrapExistingEmptyWinsAndReferenceFails(t *testing.T) { type config struct { 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) if value, present := os.LookupEnv(name); !present || value != "" { t.Fatalf("environment changed to %q, %v", value, present) @@ -339,7 +339,7 @@ func TestDynamicValuesDoNotEstablishSecretReferences(t *testing.T) { } } 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++ return "unexpected", true }, Options[config]{}) @@ -354,9 +354,9 @@ func TestLoadDoesNotMutateSecretEnvironment(t *testing.T) { type config struct { 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" { - t.Fatalf("Load() = %#v, %v", result, err) + t.Fatalf("testLoad() = %#v, %v", result, err) } if got := os.Getenv(name); got != "original" { t.Fatalf("environment = %q", got) diff --git a/internal/engine/source_fifo_test.go b/internal/engine/source_fifo_test.go index 2f698f6..a80d404 100644 --- a/internal/engine/source_fifo_test.go +++ b/internal/engine/source_fifo_test.go @@ -26,7 +26,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) { case "profile": opts.Profile = "fifo" } - _, err := Load(source, opts) + _, err := testLoad(source, opts) var loadErr *LoadError if !errors.As(err, &loadErr) { t.Fatalf("expected safe load error, got %v", err) diff --git a/internal/engine/validation.go b/internal/engine/validation.go index 44bba41..d0736d8 100644 --- a/internal/engine/validation.go +++ b/internal/engine/validation.go @@ -110,7 +110,7 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch return canonicalDiagnostics(state.diagnostics) } 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)) } return canonicalDiagnostics(state.diagnostics) diff --git a/internal/engine/validation_test.go b/internal/engine/validation_test.go index cce1b83..f1651de 100644 --- a/internal/engine/validation_test.go +++ b/internal/engine/validation_test.go @@ -14,7 +14,7 @@ func TestValidationCollectsSchemaAwareReportsAndNearestLocations(t *testing.T) { Missing string `yaml:"missing"` 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) { validation.Error(Keys("missing"), "is required", Keys("primary")) validation.Error(Keys("dynamic", "missing"), "dynamic entry is required") @@ -58,7 +58,7 @@ func TestValidationRejectsUnknownStructPaths(t *testing.T) { type config struct { 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) { validation.Error(Keys("unknown"), "must be configured") }, @@ -73,7 +73,7 @@ func TestValidationMutationFailsWithoutPublishingResult(t *testing.T) { Value int `yaml:"value"` 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) { configuration.Value = 2 configuration.Items["one"] = 2 @@ -127,7 +127,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) { 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 { t.Fatal(err) } @@ -148,7 +148,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) { Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil }, 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) if !reflect.DeepEqual(events, []string{"version"}) { t.Fatalf("events = %#v", events) @@ -157,7 +157,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) { t.Run("resolution error stops validation", func(t *testing.T) { 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 { resolution.Error(Keys("value"), "cannot resolve") return nil @@ -172,7 +172,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) { t.Run("decode error stops application hooks", func(t *testing.T) { 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 }, Validate: func(_ *config, _ *Validation) { validated = true }, }}) @@ -190,7 +190,7 @@ func TestCompleteSingleSourceLifecycleKeepsMetadataAgreement(t *testing.T) { Input int `yaml:"input"` 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} }, Resolve: func(configuration *config, resolution *Resolution) error { Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input")) diff --git a/internal/engine/value_operation.go b/internal/engine/value_operation.go index 08661df..31872bd 100644 --- a/internal/engine/value_operation.go +++ b/internal/engine/value_operation.go @@ -34,10 +34,10 @@ type valueOperation struct { } 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"} } - 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) if issue != nil { return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"} diff --git a/internal/engine/version_path_test.go b/internal/engine/version_path_test.go index d266938..b5aa864 100644 --- a/internal/engine/version_path_test.go +++ b/internal/engine/version_path_test.go @@ -15,7 +15,7 @@ func TestVersionPathsShareCanonicalSequenceSemantics(t *testing.T) { t.Fatal(err) } 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 }}, }}) want := Version{} @@ -49,7 +49,7 @@ func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) { if err != nil { 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 { if !v.Present || v.Text != "4" { t.Errorf("checked = %#v", v) @@ -77,7 +77,7 @@ func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) { type config struct { 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 { t.Errorf("wrong segment kind found version: %#v", v) }