Provide passive per-source resource contexts
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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](),
|
||||||
|
|||||||
@@ -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") }},
|
||||||
|
|||||||
221
facade_resource_context_test.go
Normal file
221
facade_resource_context_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
132
internal/engine/resource_context.go
Normal file
132
internal/engine/resource_context.go
Normal 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)
|
||||||
|
}
|
||||||
133
internal/engine/resource_context_test.go
Normal file
133
internal/engine/resource_context_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/engine/source_context.go
Normal file
112
internal/engine/source_context.go
Normal 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
32
resource_context.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user