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
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
**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
|
||||
sources, in private inspection and live resolution state. Keep descriptors
|
||||
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.Version](), reflect.TypeFor[configkit.VersionRule](),
|
||||
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.Result[struct{}]](),
|
||||
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
||||
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
||||
{"InputInfo", func() { state.resolution.InputInfo(path) }},
|
||||
{"Source", func() { state.resolution.Source("source") }},
|
||||
{"SourceContext", func() { state.resolution.SourceContext("source") }},
|
||||
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
|
||||
{"Resolution.Error", func() { state.resolution.Error(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 {
|
||||
metadata Metadata
|
||||
sources []SourceInfo
|
||||
contexts map[string]ResourceContext
|
||||
diagnostics []Diagnostic
|
||||
baseline *document.Node
|
||||
input *document.Node
|
||||
@@ -105,6 +106,16 @@ func (r *Inspection) Sources() []SourceInfo {
|
||||
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.
|
||||
func (r *Inspection) Diagnostics() []Diagnostic {
|
||||
if r == nil {
|
||||
@@ -167,6 +178,22 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
if len(diagnostics) != 0 {
|
||||
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)
|
||||
accumulator.add(diagnostics...)
|
||||
|
||||
@@ -12,6 +12,7 @@ type assembledInput struct {
|
||||
document *document.Node
|
||||
composition *composedDocument
|
||||
sources []SourceInfo
|
||||
contexts map[string]ResourceContext
|
||||
}
|
||||
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -94,6 +102,11 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
|
||||
if len(diagnostics) != 0 {
|
||||
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 {
|
||||
input.sources, diagnostics = assembly.applyOverrides(
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
type resolutionInput struct {
|
||||
metadata Metadata
|
||||
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{
|
||||
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
||||
effectivePaths: effectivePathIndex(final),
|
||||
@@ -32,7 +33,11 @@ func newResolutionInput(metadata Metadata, final *document.Node, sources []Sourc
|
||||
}
|
||||
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) {
|
||||
@@ -47,6 +52,11 @@ func (input resolutionInput) source(id string) (SourceInfo, bool) {
|
||||
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 {
|
||||
metadata := Metadata{
|
||||
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) {
|
||||
groups := make([]*composedDocument, 0, len(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...)
|
||||
mergeResourceContexts(&input.contexts, contexts)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
@@ -106,6 +107,11 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
if len(diagnostics) != 0 {
|
||||
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 {
|
||||
var diagnostics []Diagnostic
|
||||
@@ -119,11 +125,12 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
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)
|
||||
hostToken := &namespaceToken{}
|
||||
var documents []*document.Node
|
||||
var sources []SourceInfo
|
||||
var contexts map[string]ResourceContext
|
||||
for _, contribution := range group.inputs {
|
||||
options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}}
|
||||
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
|
||||
sources = append(sources, source)
|
||||
}
|
||||
mergeResourceContexts(&contexts, acquired.contexts)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, sources, diagnostics
|
||||
return nil, sources, contexts, diagnostics
|
||||
}
|
||||
if acquired.document == nil {
|
||||
continue
|
||||
}
|
||||
if node := fragmentVersionClaim(acquired.document, rule); node != nil {
|
||||
location := publicLocation(node.Location)
|
||||
return nil, sources, []Diagnostic{{
|
||||
return nil, sources, contexts, []Diagnostic{{
|
||||
Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
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 {
|
||||
return nil, sources, nil
|
||||
return nil, sources, contexts, nil
|
||||
}
|
||||
composition, diagnostics := composeDocuments(documents...)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, sources, diagnostics
|
||||
return nil, sources, contexts, diagnostics
|
||||
}
|
||||
for pointer, origin := range composition.origins {
|
||||
origin.Layer = LayerUnderlay
|
||||
@@ -167,5 +175,5 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
|
||||
for index := range composition.applications {
|
||||
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}
|
||||
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 {
|
||||
t.Fatalf("shared source budget = %#v", diagnostics)
|
||||
}
|
||||
accounting = &importAccounting{bytes: maxTotalBytes - 2}
|
||||
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 {
|
||||
t.Fatalf("shared byte budget = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type resolvedLoad[T any] struct {
|
||||
final *document.Node
|
||||
snapshots []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
contexts map[string]ResourceContext
|
||||
}
|
||||
|
||||
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
|
||||
var resolutionSnapshots []resolutionSnapshot
|
||||
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)
|
||||
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||
if hasErrors(resolved.diagnostics) {
|
||||
@@ -67,7 +68,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
} else if input.document != nil {
|
||||
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) {
|
||||
@@ -95,6 +96,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
||||
inspection := &Inspection{
|
||||
metadata: metadata,
|
||||
sources: cloneSourceInfos(accumulator.sources),
|
||||
contexts: cloneResourceContexts(resolved.contexts),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
baseline: decoded.baseline,
|
||||
input: decoded.final,
|
||||
|
||||
@@ -72,6 +72,12 @@ func (r *Resolution) Source(id string) (SourceInfo, bool) {
|
||||
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
|
||||
// mutations to its descendants.
|
||||
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