Demonstrate source-aware path normalization

This commit is contained in:
2026-09-12 13:21:21 +00:00
parent cc98be8f0a
commit b9e4dfac92
9 changed files with 242 additions and 1 deletions

View File

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

View File

@@ -218,7 +218,7 @@ remaining Stage 3 risks were identified.
## Stage 4 — Demonstrate source-aware normalization and safe inspection ## Stage 4 — Demonstrate source-aware normalization and safe inspection
Status: not started. Dependencies: Stage 3. Status: complete (2026-09-12). Dependencies: Stage 3.
Scope: maintained Notarius-shaped example code/fixtures, executable examples, Scope: maintained Notarius-shaped example code/fixtures, executable examples,
explanation/secret integration tests, and consumer guidance. explanation/secret integration tests, and consumer guidance.
@@ -260,6 +260,20 @@ workflows. Filesystem policy stays in the example application, not the root libr
Validation: `go test ./examples/consumers`, Validation: `go test ./examples/consumers`,
`go test . -run 'Resolution|Explain|Secret|Example'`, then the everyday checker. `go test . -run 'Resolution|Explain|Secret|Example'`, then the everyday checker.
Completion record (2026-09-12): added the maintained `NotariusPathConfig` example
and fixtures. Its application-owned resolver uses `InputInfo`/`Source` only: file
values use their exact declaring file, defaults use `source-1`, CLI values use the
explicit invocation directory, and absolute values remain unchanged. Missing CLI
bases or missing/composite file provenance retain the relative value and report an
application warning. Tests prove imported-file, default, CLI, absolute, and canonical
symlink-target bases without changing process CWD; the symlink test explicitly skips
when the platform cannot create it. They also prove effective/explanation agreement,
retained original declaration and reason, sensitive redaction, and that provenance
queries do not re-read a reader source or resolve credentials before normal lookup.
The consumer guide links to the executable artifact. `go test ./examples/consumers`,
`go test . -run 'Resolution|Explain|Secret|Example'`, and `./scripts/check.sh` passed
with Go 1.26.7 on Linux. No deviations or remaining Stage 4 risks were identified.
## Stage 5 — Audit the complete contract and hand off release readiness ## Stage 5 — Audit the complete contract and hand off release readiness
Status: not started. Dependencies: Stage 4. Status: not started. Dependencies: Stage 4.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,16 @@ type secretReferenceConfig struct {
Sequence []Secret `yaml:"sequence"` Sequence []Secret `yaml:"sequence"`
} }
type resolutionQueryReader struct {
input *strings.Reader
reads int
}
func (reader *resolutionQueryReader) Read(data []byte) (int, error) {
reader.reads++
return reader.input.Read(data)
}
func TestSecretReferencesResolveTypedShapesOncePerName(t *testing.T) { func TestSecretReferencesResolveTypedShapesOncePerName(t *testing.T) {
input := `required: SHARED_SECRET input := `required: SHARED_SECRET
optional: OPTIONAL_SECRET optional: OPTIONAL_SECRET
@@ -152,6 +162,50 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
} }
} }
func TestResolutionInputQueriesDoNotReadOrResolveSecrets(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
Secret Secret `yaml:"secret"`
}
reader := &resolutionQueryReader{input: strings.NewReader("value: source\nsecret: QUERY_SECRET\n")}
lookups := 0
var callbackErr error
result, err := loadWithSecretLookup(Reader("query.yml", reader), func(name string) (string, bool) {
lookups++
if name != "QUERY_SECRET" {
t.Fatalf("secret lookup name = %q", name)
}
return "query-credential", true
}, Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
reads := reader.reads
value, valueFound := resolution.InputInfo(Keys("value"))
secret, secretFound := resolution.InputInfo(Keys("secret"))
if !valueFound || !secretFound || len(value.Origin.Locations) != 1 || len(secret.Origin.Locations) != 1 {
callbackErr = errors.New("entry metadata is incomplete")
return callbackErr
}
if source, found := resolution.Source(value.Origin.Locations[0].SourceID); !found || source.Path != "" {
callbackErr = errors.New("entry source is incomplete")
return callbackErr
}
if reader.reads != reads || lookups != 0 {
callbackErr = errors.New("entry query performed input or credential access")
}
return callbackErr
}}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
if lookups != 1 || result.Config.Secret.Value() != "query-credential" {
t.Fatalf("secret resolution = lookups %d config %#v", lookups, result.Config)
}
}
func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) { func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`