Demonstrate source-aware path normalization
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
The examples preserve domain meaning while adopting one configuration convention:
|
||||
|
||||
@@ -218,7 +218,7 @@ remaining Stage 3 risks were identified.
|
||||
|
||||
## 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,
|
||||
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`,
|
||||
`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
|
||||
|
||||
Status: not started. Dependencies: Stage 4.
|
||||
|
||||
@@ -4,6 +4,7 @@ package consumers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -146,6 +147,69 @@ func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
|
||||
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.
|
||||
type ModuleOptions struct {
|
||||
WindowUnits *int `yaml:"window_units"`
|
||||
|
||||
@@ -29,6 +29,12 @@ func Example_notarius() {
|
||||
// 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() {
|
||||
defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource())
|
||||
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 {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
|
||||
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() {
|
||||
value, present := os.LookupEnv(name)
|
||||
return func() {
|
||||
|
||||
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
reference: ../assets/imported.txt
|
||||
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: declarations/path.yml
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
reference: ../assets/target.txt
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
@@ -18,6 +18,16 @@ type secretReferenceConfig struct {
|
||||
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) {
|
||||
input := `required: SHARED_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) {
|
||||
type config struct {
|
||||
Secret Secret `yaml:"secret"`
|
||||
|
||||
Reference in New Issue
Block a user