Provide passive per-source resource contexts

This commit is contained in:
2026-09-29 18:08:27 +00:00
parent 5a77728a81
commit 36362ee6af
16 changed files with 752 additions and 13 deletions

View File

@@ -148,6 +148,33 @@ have index zero. Explicit empty groups and `NoSource` consume no source ID.
Byte, source, node, and glob-entry budgets are shared across the load, while Byte, source, node, and glob-entry budgets are shared across the load, while
the existing per-source and import-depth bounds still apply. the existing per-source and import-depth bounds still apply.
### Source resource contexts
`Result.SourceContext(id)` and callback-scoped `Resolution.SourceContext(id)`
return a passive `ResourceContext` for a source with filesystem context, or
false for unknown and contextless IDs. The result query is nil-safe; the
resolution query follows the callback's lifetime guard. Queries perform no
filesystem access. Obtain IDs from exact origin locations rather than guessing
one context for a composite origin or an application-derived value.
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
is its slash-relative directory inside that root. This includes imported
sources, selected overlays, host files even without enabled imports, native
files, and byte, reader, or mapping inputs with an explicit context. CLI and
contextless synthetic inputs have no resource context. To open a relative
resource, join `BaseDir` with the application resource name using slash-path
operations and open that path through `FS`.
Native contexts borrow the caller's filesystem within the configured root.
Host contexts retain a canonical root path, not an open root handle; each
resource `Open` checks containment and opens a fresh handle that the returned
file's `Close` releases. Applications own and close the files they open.
Resources are application data, not configuration input, and do not consume
configuration byte or node budgets. The loaded configuration and metadata stay
usable if source files later change or disappear. Later resource operations
observe the backing filesystem then and may fail if files move, disappear, or
a caller-owned filesystem closes. This is not a snapshot or universal sandbox.
### Application schema versions ### Application schema versions
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed `VersionRule` declares a semantic path and non-nil checker. Before defaults or typed

View File

@@ -425,6 +425,18 @@ configuration, metadata, and safe rendering agree. Run the everyday checker and
## Stage 8: Add handle-free source-context queries ## Stage 8: Add handle-free source-context queries
**Completed 2026-09-29.** Results and active resolution callbacks now expose
passive per-source `ResourceContext` records. Native views borrow caller-owned
filesystems under their configured roots; host views retain only canonical root
paths and open/close fresh roots with each resource file. Main, imported,
selected-overlay, and explicitly contextual synthetic sources have their own
base directories; contextless and CLI sources do not. Public tests verify
origin-based resource access, no I/O on queries, later mutation/deletion,
borrowed-filesystem closure, and callback guards. Tracking tests cover resource
Open failure cleanup, returned file/directory ownership, and loading-handle
closure across success and representative failures. The everyday checker and
full race suite passed.
1. Retain context descriptors keyed by acquired source ID, including imported 1. Retain context descriptors keyed by acquired source ID, including imported
sources, in private inspection and live resolution state. Keep descriptors sources, in private inspection and live resolution state. Keep descriptors
separate from serializable metadata and preserve callback guards. separate from serializable metadata and preserve callback guards.

View File

@@ -31,6 +31,9 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) {
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](), reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](), reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](), reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
reflect.TypeFor[configkit.Filesystem](), reflect.TypeFor[configkit.ResourceContext](),
reflect.TypeFor[configkit.Input](), reflect.TypeFor[configkit.Underlay](),
reflect.TypeFor[configkit.LayeredOptions[struct{}]](),
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](), reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
reflect.TypeFor[configkit.Result[struct{}]](), reflect.TypeFor[configkit.Result[struct{}]](),
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](), reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),

View File

@@ -26,6 +26,7 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
{"IsExplicit", func() { state.resolution.IsExplicit(path) }}, {"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }}, {"InputInfo", func() { state.resolution.InputInfo(path) }},
{"Source", func() { state.resolution.Source("source") }}, {"Source", func() { state.resolution.Source("source") }},
{"SourceContext", func() { state.resolution.SourceContext("source") }},
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }}, {"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
{"Resolution.Error", func() { state.resolution.Error(path, "message") }}, {"Resolution.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},

View File

@@ -0,0 +1,221 @@
package configkit_test
import (
"errors"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type observedResourceFS struct {
files fstest.MapFS
opens int
closed bool
}
func (f *observedResourceFS) Open(name string) (fs.File, error) {
f.opens++
if f.closed {
return nil, fs.ErrClosed
}
return f.files.Open(name)
}
type resourceFacadeConfig struct {
Native string `yaml:"native"`
Shared string `yaml:"shared"`
Host string `yaml:"host"`
Selected string `yaml:"selected"`
}
func TestFacadeSourceContextsFollowOriginsWithoutQueryIO(t *testing.T) {
host := t.TempDir()
if err := os.WriteFile(filepath.Join(host, "main.yml"), []byte("host: host.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(host, "host.txt"), []byte("host-v1"), 0o600); err != nil {
t.Fatal(err)
}
borrowed := &observedResourceFS{files: fstest.MapFS{
"bundle/config/defaults.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nnative: icon.txt\n")},
"bundle/shared.yml": &fstest.MapFile{Data: []byte("shared: shared.txt\n")},
"bundle/overlays/test.yml": &fstest.MapFile{Data: []byte("selected: overlay.txt\n")},
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon-v1")},
"bundle/shared.txt": &fstest.MapFile{Data: []byte("shared-v1")},
"bundle/overlays/overlay.txt": &fstest.MapFile{Data: []byte("overlay-v1")},
}}
namespace := configkit.NewFilesystem("bundle", borrowed)
var resolverContext configkit.ResourceContext
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(host, "main.yml"))}, configkit.LayeredOptions[resourceFacadeConfig]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: namespace.File("bundle/config/defaults.yml"), Imports: true,
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
}}}},
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle", FS: &namespace},
Definition: configkit.Definition[resourceFacadeConfig]{Resolve: func(config *resourceFacadeConfig, resolution *configkit.Resolution) error {
before := borrowed.opens
var ok bool
resolverContext, ok = resolution.SourceContext("source-2")
if !ok || resolverContext.BaseDir != "config" || resolverContext.FS == nil {
t.Errorf("resolver context = %#v, %v", resolverContext, ok)
}
if _, ok := resolution.SourceContext("missing"); ok || borrowed.opens != before {
t.Error("resolution context query performed I/O or guessed an unknown source")
}
return nil
}},
})
if err != nil {
t.Fatalf("load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
}
queriesBefore := borrowed.opens
for _, test := range []struct {
field, wantBase, wantData string
}{
{"native", "config", "icon-v1"},
{"shared", ".", "shared-v1"},
{"selected", "overlays", "overlay-v1"},
{"host", ".", "host-v1"},
} {
origin, ok := result.Metadata().Lookup(configkit.Keys(test.field))
if !ok || len(origin.Origin.Locations) != 1 {
t.Fatalf("%s origin = %#v, %v", test.field, origin, ok)
}
context, ok := result.SourceContext(origin.Origin.Locations[0].SourceID)
if !ok || context.FS == nil || context.BaseDir != test.wantBase {
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
}
if borrowed.opens != queriesBefore {
t.Fatal("result SourceContext query performed filesystem I/O")
}
resourceName := path.Join(context.BaseDir, map[string]string{
"native": result.Config.Native, "shared": result.Config.Shared,
"selected": result.Config.Selected, "host": result.Config.Host,
}[test.field])
data, err := fs.ReadFile(context.FS, resourceName)
if err != nil || string(data) != test.wantData {
t.Fatalf("%s resource = %q, %v", test.field, data, err)
}
queriesBefore = borrowed.opens
}
if _, ok := result.SourceContext(""); ok {
t.Fatal("empty source ID returned a context")
}
var nilResult *configkit.Result[resourceFacadeConfig]
if _, ok := nilResult.SourceContext("source-2"); ok {
t.Fatal("nil result returned a context")
}
if _, err := fs.ReadFile(resolverContext.FS, "../shared.txt"); !errors.Is(err, fs.ErrInvalid) {
t.Fatalf("native root escape = %v", err)
}
borrowed.files["bundle/config/icon.txt"].Data = []byte("icon-v2")
data, err := fs.ReadFile(resolverContext.FS, "config/icon.txt")
if err != nil || string(data) != "icon-v2" {
t.Fatalf("updated native resource = %q, %v", data, err)
}
borrowed.closed = true
if result.Config.Native != "icon.txt" {
t.Fatal("closing caller filesystem altered loaded configuration")
}
if _, err := fs.ReadFile(resolverContext.FS, "config/icon.txt"); !errors.Is(err, fs.ErrClosed) {
t.Fatalf("closed caller filesystem error = %v", err)
}
}
func TestFacadeHostContextSurvivesConfigurationDeletion(t *testing.T) {
root := t.TempDir()
main := filepath.Join(root, "main.yml")
resource := filepath.Join(root, "asset.txt")
if err := os.WriteFile(main, []byte("native: asset.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resource, []byte("first"), 0o600); err != nil {
t.Fatal(err)
}
result, err := configkit.Load[resourceFacadeConfig](configkit.File(main))
if err != nil {
t.Fatal(err)
}
context, ok := result.SourceContext("source-1")
if !ok || context.BaseDir != "." {
t.Fatalf("host context = %#v, %v", context, ok)
}
if err := os.Remove(main); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resource, []byte("second"), 0o600); err != nil {
t.Fatal(err)
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Native))
if err != nil || string(data) != "second" || result.Config.Native != "asset.txt" {
t.Fatalf("resource after source deletion = %q, %v", data, err)
}
if _, err := fs.ReadFile(context.FS, "../asset.txt"); !errors.Is(err, fs.ErrInvalid) {
t.Fatalf("invalid host path = %v", err)
}
if err := os.Remove(resource); err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(context.FS, "asset.txt"); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("missing later resource = %v", err)
}
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "escape")); err == nil {
if _, err := fs.ReadFile(context.FS, "escape"); !errors.Is(err, fs.ErrPermission) {
t.Fatalf("host symlink escape = %v", err)
}
}
}
func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon")},
})
context := &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle/config", FS: &namespace}
for _, test := range []struct {
name string
source configkit.Source
}{
{"bytes", configkit.Bytes("memory", []byte("native: icon.txt\n"))},
{"reader", configkit.Reader("stream", strings.NewReader("native: icon.txt\n"))},
{"mapping", configkit.Mapping("values", map[string]any{"native": "icon.txt"})},
} {
t.Run(test.name, func(t *testing.T) {
result, err := configkit.Load(test.source, configkit.Options[resourceFacadeConfig]{Filesystem: context})
if err != nil {
t.Fatal(err)
}
resource, ok := result.SourceContext("source-1")
if !ok || resource.BaseDir != "config" {
t.Fatalf("synthetic context = %#v, %v", resource, ok)
}
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, result.Config.Native))
if err != nil || string(data) != "icon" {
t.Fatalf("synthetic resource = %q, %v", data, err)
}
})
}
result, err := configkit.Load[resourceFacadeConfig](configkit.Bytes("plain", []byte("native: none\n")))
if err != nil {
t.Fatal(err)
}
if _, ok := result.SourceContext("source-1"); ok {
t.Fatal("contextless byte source returned a context")
}
result, err = configkit.Load(configkit.NoSource(), configkit.Options[resourceFacadeConfig]{Overrides: []configkit.Override{{Path: configkit.Keys("native"), Value: "from-cli", Flag: "--native"}}})
if err != nil {
t.Fatal(err)
}
if _, ok := result.SourceContext("source-1"); ok {
t.Fatal("CLI source returned a context")
}
}

View File

@@ -78,6 +78,7 @@ type Loaded[T any] struct {
type Inspection struct { type Inspection struct {
metadata Metadata metadata Metadata
sources []SourceInfo sources []SourceInfo
contexts map[string]ResourceContext
diagnostics []Diagnostic diagnostics []Diagnostic
baseline *document.Node baseline *document.Node
input *document.Node input *document.Node
@@ -105,6 +106,16 @@ func (r *Inspection) Sources() []SourceInfo {
return cloneSourceInfos(r.sources) return cloneSourceInfos(r.sources)
} }
// SourceContext returns a passive filesystem view for an acquired source.
// Unknown IDs and sources without a filesystem context return false.
func (r *Inspection) SourceContext(id string) (ResourceContext, bool) {
if r == nil {
return ResourceContext{}, false
}
context, ok := r.contexts[id]
return context, ok
}
// Diagnostics returns detached nonfatal diagnostics from the load. // Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Inspection) Diagnostics() []Diagnostic { func (r *Inspection) Diagnostics() []Diagnostic {
if r == nil { if r == nil {
@@ -167,6 +178,22 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
if request.ordinary && input.parsed.source != nil {
baseSources := make([]SourceInfo, 0, len(input.sources))
for _, source := range input.sources {
if source.Layer == LayerBase {
baseSources = append(baseSources, source)
}
}
if len(baseSources) != 0 && input.contexts[baseSources[0].ID].FS == nil {
contexts, diagnostic := contextsForAcquiredSources(request.main.source, request.main.filesystem, input.parsed.source, baseSources)
if diagnostic != nil {
accumulator.add(*diagnostic)
return nil, accumulator.loadError()
}
mergeResourceContexts(&input.contexts, contexts)
}
}
decoded, diagnostics := decodeLoadInput(schema, request.definition, input) decoded, diagnostics := decodeLoadInput(schema, request.definition, input)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)

View File

@@ -12,6 +12,7 @@ type assembledInput struct {
document *document.Node document *document.Node
composition *composedDocument composition *composedDocument
sources []SourceInfo sources []SourceInfo
contexts map[string]ResourceContext
} }
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) { func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
@@ -56,6 +57,13 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], accounti
accounting.bytes += parsed.bytes accounting.bytes += parsed.bytes
accounting.nodes += countInputNodes(input.document) accounting.nodes += countInputNodes(input.document)
} }
if !activeOverlay(options.Overlay) || options.Imports {
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.contexts = contexts
}
return input, nil return input, nil
} }
@@ -94,6 +102,11 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, input.parsed.source, input.sources[len(input.sources)-1:])
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
mergeResourceContexts(&input.contexts, contexts)
} }
if len(options.Overrides) != 0 { if len(options.Overrides) != 0 {
input.sources, diagnostics = assembly.applyOverrides( input.sources, diagnostics = assembly.applyOverrides(

View File

@@ -12,9 +12,10 @@ import (
type resolutionInput struct { type resolutionInput struct {
metadata Metadata metadata Metadata
sources map[string]SourceInfo sources map[string]SourceInfo
contexts map[string]ResourceContext
} }
func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo) resolutionInput { func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo, contexts ...map[string]ResourceContext) resolutionInput {
frozen := Metadata{ frozen := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)), fields: make(map[string]FieldInfo, len(metadata.fields)),
effectivePaths: effectivePathIndex(final), effectivePaths: effectivePathIndex(final),
@@ -32,7 +33,11 @@ func newResolutionInput(metadata Metadata, final *document.Node, sources []Sourc
} }
indexed[source.ID] = cloneSourceInfo(source) indexed[source.ID] = cloneSourceInfo(source)
} }
return resolutionInput{metadata: frozen, sources: indexed} result := resolutionInput{metadata: frozen, sources: indexed}
if len(contexts) != 0 {
result.contexts = cloneResourceContexts(contexts[0])
}
return result
} }
func (input resolutionInput) lookup(path Path) (FieldInfo, bool) { func (input resolutionInput) lookup(path Path) (FieldInfo, bool) {
@@ -47,6 +52,11 @@ func (input resolutionInput) source(id string) (SourceInfo, bool) {
return cloneSourceInfo(source), true return cloneSourceInfo(source), true
} }
func (input resolutionInput) sourceContext(id string) (ResourceContext, bool) {
context, ok := input.contexts[id]
return context, ok
}
func metadataFromDecoded(decoded decodedConfiguration) Metadata { func metadataFromDecoded(decoded decodedConfiguration) Metadata {
metadata := Metadata{ metadata := Metadata{
fields: make(map[string]FieldInfo), fields: make(map[string]FieldInfo),

View File

@@ -56,8 +56,9 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) { func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
groups := make([]*composedDocument, 0, len(request.underlays)) groups := make([]*composedDocument, 0, len(request.underlays))
for index, group := range request.underlays { for index, group := range request.underlays {
composition, sources, diagnostics := acquireUnderlayGroup(group, index+1, request.definition.Version, accounting) composition, sources, contexts, diagnostics := acquireUnderlayGroup(group, index+1, request.definition.Version, accounting)
input.sources = append(input.sources, sources...) input.sources = append(input.sources, sources...)
mergeResourceContexts(&input.contexts, contexts)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
@@ -106,6 +107,11 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
contexts, diagnostic := contextsForAcquiredSources(selection, request.overlayFilesystem, selected.source, input.sources[len(input.sources)-1:])
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
mergeResourceContexts(&input.contexts, contexts)
} }
if len(request.overrides) != 0 { if len(request.overrides) != 0 {
var diagnostics []Diagnostic var diagnostics []Diagnostic
@@ -119,11 +125,12 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
return input, nil return input, nil
} }
func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) { func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
visited := make(map[resourceIdentity]importRoute) visited := make(map[resourceIdentity]importRoute)
hostToken := &namespaceToken{} hostToken := &namespaceToken{}
var documents []*document.Node var documents []*document.Node
var sources []SourceInfo var sources []SourceInfo
var contexts map[string]ResourceContext
for _, contribution := range group.inputs { for _, contribution := range group.inputs {
options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}} options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}}
acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, accounting, visited, hostToken) acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, accounting, visited, hostToken)
@@ -132,15 +139,16 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
source.UnderlayIndex = index source.UnderlayIndex = index
sources = append(sources, source) sources = append(sources, source)
} }
mergeResourceContexts(&contexts, acquired.contexts)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, sources, diagnostics return nil, sources, contexts, diagnostics
} }
if acquired.document == nil { if acquired.document == nil {
continue continue
} }
if node := fragmentVersionClaim(acquired.document, rule); node != nil { if node := fragmentVersionClaim(acquired.document, rule); node != nil {
location := publicLocation(node.Location) location := publicLocation(node.Location)
return nil, sources, []Diagnostic{{ return nil, sources, contexts, []Diagnostic{{
Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(), Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location, Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location,
}} }}
@@ -154,11 +162,11 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
} }
} }
if len(documents) == 0 { if len(documents) == 0 {
return nil, sources, nil return nil, sources, contexts, nil
} }
composition, diagnostics := composeDocuments(documents...) composition, diagnostics := composeDocuments(documents...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, sources, diagnostics return nil, sources, contexts, diagnostics
} }
for pointer, origin := range composition.origins { for pointer, origin := range composition.origins {
origin.Layer = LayerUnderlay origin.Layer = LayerUnderlay
@@ -167,5 +175,5 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
for index := range composition.applications { for index := range composition.applications {
composition.applications[index].layer = LayerUnderlay composition.applications[index].layer = LayerUnderlay
} }
return composition, sources, nil return composition, sources, contexts, nil
} }

View File

@@ -169,13 +169,13 @@ func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) {
accounting := &importAccounting{sources: maxImportSources - 1} accounting := &importAccounting{sources: maxImportSources - 1}
group := loadGroup{inputs: []loadInput{{source: Mapping("first", map[string]any{})}, {source: Mapping("second", map[string]any{})}}} group := loadGroup{inputs: []loadInput{{source: Mapping("first", map[string]any{})}, {source: Mapping("second", map[string]any{})}}}
_, _, diagnostics := acquireUnderlayGroup(group, 1, nil, accounting) _, _, _, diagnostics := acquireUnderlayGroup(group, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("shared source budget = %#v", diagnostics) t.Fatalf("shared source budget = %#v", diagnostics)
} }
accounting = &importAccounting{bytes: maxTotalBytes - 2} accounting = &importAccounting{bytes: maxTotalBytes - 2}
group.inputs = []loadInput{{source: Mapping("first", map[string]any{"port": "one"})}} group.inputs = []loadInput{{source: Mapping("first", map[string]any{"port": "one"})}}
_, _, diagnostics = acquireUnderlayGroup(group, 1, nil, accounting) _, _, _, diagnostics = acquireUnderlayGroup(group, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("shared byte budget = %#v", diagnostics) t.Fatalf("shared byte budget = %#v", diagnostics)
} }

View File

@@ -15,6 +15,7 @@ type resolvedLoad[T any] struct {
final *document.Node final *document.Node
snapshots []resolutionSnapshot snapshots []resolutionSnapshot
applications []historyApplication applications []historyApplication
contexts map[string]ResourceContext
} }
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) { func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
@@ -51,7 +52,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
final := decoded.final final := decoded.final
var resolutionSnapshots []resolutionSnapshot var resolutionSnapshots []resolutionSnapshot
if definition.Resolve != nil { if definition.Resolve != nil {
entry := newResolutionInput(metadata, decoded.final, input.sources) entry := newResolutionInput(metadata, decoded.final, input.sources, input.contexts)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry) resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
diagnostics = append(diagnostics, resolved.diagnostics...) diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) { if hasErrors(resolved.diagnostics) {
@@ -67,7 +68,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} else if input.document != nil { } else if input.document != nil {
applications = []historyApplication{{layer: LayerBase, value: input.document}} applications = []historyApplication{{layer: LayerBase, value: input.document}}
} }
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications}, diagnostics return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, contexts: input.contexts}, diagnostics
} }
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) { func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
@@ -95,6 +96,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
inspection := &Inspection{ inspection := &Inspection{
metadata: metadata, metadata: metadata,
sources: cloneSourceInfos(accumulator.sources), sources: cloneSourceInfos(accumulator.sources),
contexts: cloneResourceContexts(resolved.contexts),
diagnostics: canonicalDiagnostics(accumulator.diagnostics), diagnostics: canonicalDiagnostics(accumulator.diagnostics),
baseline: decoded.baseline, baseline: decoded.baseline,
input: decoded.final, input: decoded.final,

View File

@@ -72,6 +72,12 @@ func (r *Resolution) Source(id string) (SourceInfo, bool) {
return r.activeState().input.source(id) return r.activeState().input.source(id)
} }
// SourceContext returns a passive context during the active resolution
// callback. Retaining the callback context after return is unsupported.
func (r *Resolution) SourceContext(id string) (ResourceContext, bool) {
return r.activeState().input.sourceContext(id)
}
// Record declares the intent for a resolver mutation. A container path covers // Record declares the intent for a resolver mutation. A container path covers
// mutations to its descendants. // mutations to its descendants.
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) { func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {

View File

@@ -0,0 +1,132 @@
package engine
import (
"errors"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
// ResourceContext is a passive copy of one acquired source's rooted filesystem
// and slash-relative base directory. FS is borrowed and opens resources only
// when the application explicitly requests them.
type ResourceContext struct {
FS fs.FS
BaseDir string
}
func cloneResourceContexts(contexts map[string]ResourceContext) map[string]ResourceContext {
if contexts == nil {
return nil
}
cloned := make(map[string]ResourceContext, len(contexts))
for id, context := range contexts {
cloned[id] = context
}
return cloned
}
// rootedNativeFS borrows a namespace without invoking SubFS or opening it at
// query time. All resource names remain relative to its configured root.
type rootedNativeFS struct {
filesystem fs.FS
root string
}
func (r rootedNativeFS) name(name string) (string, error) {
if !fs.ValidPath(name) || strings.Contains(name, "\\") {
return "", &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
joined := path.Join(r.root, name)
if !fs.ValidPath(joined) || r.root != "." && joined != r.root && !strings.HasPrefix(joined, r.root+"/") {
return "", &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission}
}
return joined, nil
}
func (r rootedNativeFS) Open(name string) (fs.File, error) {
joined, err := r.name(name)
if err != nil {
return nil, err
}
return r.filesystem.Open(joined)
}
func (r rootedNativeFS) ReadDir(name string) ([]fs.DirEntry, error) {
joined, err := r.name(name)
if err != nil {
return nil, err
}
return fs.ReadDir(r.filesystem, joined)
}
// rootedHostFS stores only a canonical root path. No os.Root is retained after
// loading or created by a SourceContext query.
type hostResourceRoot interface {
Open(string) (*os.File, error)
Close() error
}
type rootedHostFS struct {
root string
openRoot func(string) (hostResourceRoot, error)
}
func (r rootedHostFS) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) || strings.Contains(name, "\\") {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
opener := r.openRoot
if opener == nil {
opener = func(name string) (hostResourceRoot, error) { return os.OpenRoot(name) }
}
root, err := opener(r.root)
if err != nil {
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
canonical, err := canonicalExistingPath(filepath.Join(r.root, filepath.FromSlash(name)))
if err != nil {
_ = root.Close()
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
if !containedPath(r.root, canonical) {
_ = root.Close()
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission}
}
relative, err := filepath.Rel(r.root, canonical)
if err != nil {
_ = root.Close()
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
file, err := root.Open(relative)
if err != nil {
_ = root.Close()
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
owned := rootedHostFile{File: file, root: root}
var opened fs.File = file
if directory, ok := opened.(fs.ReadDirFile); ok {
return &rootedHostDirFile{rootedHostFile: owned, directory: directory}, nil
}
return &owned, nil
}
type rootedHostFile struct {
fs.File
root hostResourceRoot
}
func (f *rootedHostFile) Close() error {
return errors.Join(f.File.Close(), f.root.Close())
}
type rootedHostDirFile struct {
rootedHostFile
directory fs.ReadDirFile
}
func (f *rootedHostDirFile) ReadDir(count int) ([]fs.DirEntry, error) {
return f.directory.ReadDir(count)
}

View File

@@ -0,0 +1,133 @@
package engine
import (
"errors"
"io/fs"
"os"
"path/filepath"
"testing"
"testing/fstest"
)
type trackedHostRoot struct {
root *os.Root
closed *int
failOpen bool
}
func (r *trackedHostRoot) Open(name string) (*os.File, error) {
if r.failOpen {
return nil, fs.ErrPermission
}
return r.root.Open(name)
}
func (r *trackedHostRoot) Close() error {
(*r.closed)++
return r.root.Close()
}
func TestRootedHostFSClosesRootOnSuccessfulAndFailedOpen(t *testing.T) {
rootPath := t.TempDir()
if err := os.WriteFile(filepath.Join(rootPath, "resource.txt"), []byte("resource"), 0o600); err != nil {
t.Fatal(err)
}
opened, closed := 0, 0
failOpen := false
filesystem := rootedHostFS{root: rootPath, openRoot: func(name string) (hostResourceRoot, error) {
root, err := os.OpenRoot(name)
if err != nil {
return nil, err
}
opened++
return &trackedHostRoot{root: root, closed: &closed, failOpen: failOpen}, nil
}}
if _, err := filesystem.Open("../resource.txt"); !errors.Is(err, fs.ErrInvalid) || opened != 0 {
t.Fatalf("invalid path = %v; roots opened %d", err, opened)
}
if _, err := filesystem.Open("missing.txt"); !errors.Is(err, fs.ErrNotExist) || opened != 1 || closed != 1 {
t.Fatalf("missing resource = %v; opened %d, closed %d", err, opened, closed)
}
failOpen = true
if _, err := filesystem.Open("resource.txt"); !errors.Is(err, fs.ErrPermission) || opened != 2 || closed != 2 {
t.Fatalf("failed inner open = %v; opened %d, closed %d", err, opened, closed)
}
failOpen = false
file, err := filesystem.Open("resource.txt")
if err != nil || opened != 3 || closed != 2 {
t.Fatalf("resource open = %v; opened %d, closed %d", err, opened, closed)
}
if err := file.Close(); err != nil || closed != 3 {
t.Fatalf("resource close = %v; opened %d, closed %d", err, opened, closed)
}
if _, err := file.Read(make([]byte, 1)); err == nil {
t.Fatal("closed resource file remained readable")
}
directory, err := filesystem.Open(".")
if err != nil {
t.Fatal(err)
}
reader, ok := directory.(fs.ReadDirFile)
if !ok {
t.Fatal("rooted host directory did not expose ReadDirFile")
}
entries, err := reader.ReadDir(-1)
if err != nil || len(entries) != 1 || entries[0].Name() != "resource.txt" {
t.Fatalf("directory entries = %#v, %v", entries, err)
}
if err := directory.Close(); err != nil || opened != 4 || closed != 4 {
t.Fatalf("directory close = %v; opened %d, closed %d", err, opened, closed)
}
}
func TestNativeResourceViewIsBorrowedAndRooted(t *testing.T) {
filesystem := &observedFilesystem{files: fstest.MapFS{
"bundle/config/resource.txt": &fstest.MapFile{Data: []byte("content")},
}}
view := rootedNativeFS{filesystem: filesystem, root: "bundle"}
if filesystem.opened != 0 {
t.Fatal("constructing a native resource view opened files")
}
data, err := fs.ReadFile(view, "config/resource.txt")
if err != nil || string(data) != "content" {
t.Fatalf("resource = %q, %v", data, err)
}
filesystem.assertClosed(t)
if _, err := fs.ReadFile(view, "../outside.txt"); !errors.Is(err, fs.ErrInvalid) {
t.Fatalf("resource escape = %v", err)
}
}
func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
type config struct {
Value string `yaml:"value"`
Version string `yaml:"version"`
}
for _, test := range []struct {
name string
main string
child string
failRead string
options Options[config]
wantErr bool
}{
{name: "success", main: "value: retained\n"},
{name: "acquisition", main: "value: retained\n", failRead: "main.yml", wantErr: true},
{name: "parse", main: "value: [broken\n", wantErr: true},
{name: "composition", main: "configkit: {imports: [{path: child.yml}]}\nvalue: first\n", child: "value: second\n", options: Options[config]{Imports: true}, wantErr: true},
{name: "version", main: "version: wrong\n", options: Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return fs.ErrInvalid }}}}, wantErr: true},
{name: "validation", main: "value: retained\n", options: Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) { validation.Error(Keys("value"), "rejected") }}}, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
files := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte(test.main)}}
if test.child != "" {
files["child.yml"] = &fstest.MapFile{Data: []byte(test.child)}
}
filesystem := &observedFilesystem{files: files, failRead: test.failRead}
_, err := testLoad(filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), test.options)
if (err != nil) != test.wantErr {
t.Fatalf("load error = %v, want failure %v", err, test.wantErr)
}
filesystem.assertClosed(t)
})
}
}

View File

@@ -0,0 +1,112 @@
package engine
import (
"io/fs"
"path"
"path/filepath"
"strings"
)
func contextsForAcquiredSources(source Source, context *FilesystemContext, baseSource *SourceInfo, sources []SourceInfo) (map[string]ResourceContext, *Diagnostic) {
if len(sources) == 0 {
return nil, nil
}
var rootPath, basePath string
var filesystem fs.FS
if source.kind == sourceFilesystem {
filesystem = source.namespace.filesystem
rootPath, basePath = ".", path.Dir(source.path)
if context != nil && context.Root != "" {
rootPath = context.Root
}
} else if context != nil && context.FS != nil {
filesystem = context.FS.namespace.filesystem
rootPath, basePath = context.Root, context.BaseDir
} else if source.kind == sourceFile {
filePath := sources[0].Path
if baseSource != nil {
filePath = baseSource.Path
}
basePath = filepath.Dir(filePath)
rootPath = basePath
if context != nil && context.Root != "" {
canonical, err := canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return nil, &value
}
rootPath = canonical
}
} else if context != nil {
var err error
rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return nil, &value
}
basePath, err = canonicalExistingPath(context.BaseDir)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
return nil, &value
}
} else {
return nil, nil
}
var rooted fs.FS
if filesystem != nil {
rooted = rootedNativeFS{filesystem: filesystem, root: rootPath}
} else {
rooted = rootedHostFS{root: rootPath}
}
result := make(map[string]ResourceContext, len(sources))
for _, acquired := range sources {
base := basePath
if acquired.Path != "" {
if filesystem != nil {
base = path.Dir(acquired.Path)
} else {
base = filepath.Dir(acquired.Path)
}
}
var relative string
if filesystem != nil {
if !fs.ValidPath(rootPath) || !fs.ValidPath(base) || rootPath != "." && base != rootPath && !strings.HasPrefix(base, rootPath+"/") {
value := importDiagnostic(CodeImportOutsideRoot, "source base is outside the configuration root", nil)
return nil, &value
}
relative = base
if rootPath != "." {
relative = strings.TrimPrefix(strings.TrimPrefix(base, rootPath), "/")
if relative == "" {
relative = "."
}
}
} else {
if !containedPath(rootPath, base) {
value := importDiagnostic(CodeImportOutsideRoot, "source base is outside the configuration root", nil)
return nil, &value
}
var err error
relative, err = filepath.Rel(rootPath, base)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "source base could not be addressed in the configuration root", nil)
return nil, &value
}
relative = filepath.ToSlash(relative)
}
result[acquired.ID] = ResourceContext{FS: rooted, BaseDir: relative}
}
return result, nil
}
func mergeResourceContexts(target *map[string]ResourceContext, incoming map[string]ResourceContext) {
if len(incoming) == 0 {
return
}
if *target == nil {
*target = make(map[string]ResourceContext, len(incoming))
}
for id, context := range incoming {
(*target)[id] = context
}
}

32
resource_context.go Normal file
View File

@@ -0,0 +1,32 @@
package configkit
import "io/fs"
// ResourceContext provides a passive rooted filesystem view and a
// slash-relative BaseDir for one acquired source. FS is borrowed: caller-owned
// filesystems remain under caller control, and host files are opened afresh.
// Querying this record performs no I/O. Resource contents may change after
// loading; callers must close files they open through FS.
type ResourceContext struct {
FS fs.FS
BaseDir string
}
// SourceContext returns the context of an acquired source ID. Unknown IDs and
// sources without context return false. It is nil-safe and performs no I/O.
// Follow individual origin locations when an origin has multiple sources.
func (r *Result[T]) SourceContext(id string) (ResourceContext, bool) {
if r == nil || r.inspection == nil {
return ResourceContext{}, false
}
context, ok := r.inspection.SourceContext(id)
return ResourceContext{FS: context.FS, BaseDir: context.BaseDir}, ok
}
// SourceContext returns a passive source context during the active resolution
// callback. Like other Resolution queries, it cannot be used after callback
// return and performs no filesystem operations itself.
func (r *Resolution) SourceContext(id string) (ResourceContext, bool) {
context, ok := r.engineContext().SourceContext(id)
return ResourceContext{FS: context.FS, BaseDir: context.BaseDir}, ok
}