From b9e4dfac929fc20991ee5a1ab5b4786cbedf616b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 12 Sep 2026 13:21:21 +0000 Subject: [PATCH] Demonstrate source-aware path normalization --- docs/consumers/README.md | 18 ++++ docs/roadmap/implementation.md | 16 +++- examples/consumers/consumers.go | 64 ++++++++++++++ examples/consumers/consumers_test.go | 83 +++++++++++++++++++ .../notarius-paths/declarations/path.yml | 1 + .../testdata/notarius-paths/defaults.yml | 1 + .../testdata/notarius-paths/main.yml | 4 + .../notarius-paths/symlink-target.yml | 2 + secret_reference_test.go | 54 ++++++++++++ 9 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 examples/consumers/testdata/notarius-paths/declarations/path.yml create mode 100644 examples/consumers/testdata/notarius-paths/defaults.yml create mode 100644 examples/consumers/testdata/notarius-paths/main.yml create mode 100644 examples/consumers/testdata/notarius-paths/symlink-target.yml diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 101be95..aaac8ac 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -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: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 932464c..061a4f2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/examples/consumers/consumers.go b/examples/consumers/consumers.go index 97d5488..922138a 100644 --- a/examples/consumers/consumers.go +++ b/examples/consumers/consumers.go @@ -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"` diff --git a/examples/consumers/consumers_test.go b/examples/consumers/consumers_test.go index af6ec00..6339741 100644 --- a/examples/consumers/consumers_test.go +++ b/examples/consumers/consumers_test.go @@ -29,6 +29,12 @@ func Example_notarius() { // Output: 4 summary 12 true } +func Example_notariusPaths() { + result, err := consumers.LoadNotariusPaths(fixture("notarius-paths", "main.yml"), "/invocation") + fmt.Println(filepath.Base(result.Config.Reference), err) + // Output: imported.txt +} + 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() { diff --git a/examples/consumers/testdata/notarius-paths/declarations/path.yml b/examples/consumers/testdata/notarius-paths/declarations/path.yml new file mode 100644 index 0000000..d31a0b7 --- /dev/null +++ b/examples/consumers/testdata/notarius-paths/declarations/path.yml @@ -0,0 +1 @@ +reference: ../assets/imported.txt diff --git a/examples/consumers/testdata/notarius-paths/defaults.yml b/examples/consumers/testdata/notarius-paths/defaults.yml new file mode 100644 index 0000000..aee91b7 --- /dev/null +++ b/examples/consumers/testdata/notarius-paths/defaults.yml @@ -0,0 +1 @@ +sensitive: PATH_REFERENCE_SECRET_CANARY diff --git a/examples/consumers/testdata/notarius-paths/main.yml b/examples/consumers/testdata/notarius-paths/main.yml new file mode 100644 index 0000000..df9daa2 --- /dev/null +++ b/examples/consumers/testdata/notarius-paths/main.yml @@ -0,0 +1,4 @@ +configkit: + imports: + - path: declarations/path.yml +sensitive: PATH_REFERENCE_SECRET_CANARY diff --git a/examples/consumers/testdata/notarius-paths/symlink-target.yml b/examples/consumers/testdata/notarius-paths/symlink-target.yml new file mode 100644 index 0000000..1571add --- /dev/null +++ b/examples/consumers/testdata/notarius-paths/symlink-target.yml @@ -0,0 +1,2 @@ +reference: ../assets/target.txt +sensitive: PATH_REFERENCE_SECRET_CANARY diff --git a/secret_reference_test.go b/secret_reference_test.go index e8e9a46..ea4d903 100644 --- a/secret_reference_test.go +++ b/secret_reference_test.go @@ -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"`