From 36362ee6aff160f4f74697f281238c508638cc2a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 29 Sep 2026 18:08:27 +0000 Subject: [PATCH] Provide passive per-source resource contexts --- docs/config.md | 27 +++ docs/roadmap/implementation.md | 12 ++ facade_candidate_test.go | 3 + facade_context_test.go | 1 + facade_resource_context_test.go | 221 +++++++++++++++++++++++ internal/engine/load.go | 27 +++ internal/engine/load_input.go | 13 ++ internal/engine/load_metadata.go | 14 +- internal/engine/load_request.go | 22 ++- internal/engine/load_request_test.go | 4 +- internal/engine/load_result.go | 6 +- internal/engine/resolution.go | 6 + internal/engine/resource_context.go | 132 ++++++++++++++ internal/engine/resource_context_test.go | 133 ++++++++++++++ internal/engine/source_context.go | 112 ++++++++++++ resource_context.go | 32 ++++ 16 files changed, 752 insertions(+), 13 deletions(-) create mode 100644 facade_resource_context_test.go create mode 100644 internal/engine/resource_context.go create mode 100644 internal/engine/resource_context_test.go create mode 100644 internal/engine/source_context.go create mode 100644 resource_context.go diff --git a/docs/config.md b/docs/config.md index b9ef3c5..d1b5754 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2ce01c8..c62e842 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/facade_candidate_test.go b/facade_candidate_test.go index 5d5c15d..3bb66ba 100644 --- a/facade_candidate_test.go +++ b/facade_candidate_test.go @@ -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](), diff --git a/facade_context_test.go b/facade_context_test.go index 309ea37..30b388a 100644 --- a/facade_context_test.go +++ b/facade_context_test.go @@ -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") }}, diff --git a/facade_resource_context_test.go b/facade_resource_context_test.go new file mode 100644 index 0000000..2ee6017 --- /dev/null +++ b/facade_resource_context_test.go @@ -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") + } +} diff --git a/internal/engine/load.go b/internal/engine/load.go index 5f6f466..c5f91b8 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -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...) diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 7f57564..5522109 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -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( diff --git a/internal/engine/load_metadata.go b/internal/engine/load_metadata.go index df2400b..0e05f79 100644 --- a/internal/engine/load_metadata.go +++ b/internal/engine/load_metadata.go @@ -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), diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index 6472057..17bd82a 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -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 } diff --git a/internal/engine/load_request_test.go b/internal/engine/load_request_test.go index 674c4b2..ae47a81 100644 --- a/internal/engine/load_request_test.go +++ b/internal/engine/load_request_test.go @@ -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) } diff --git a/internal/engine/load_result.go b/internal/engine/load_result.go index 4f8562c..6e2857f 100644 --- a/internal/engine/load_result.go +++ b/internal/engine/load_result.go @@ -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, diff --git a/internal/engine/resolution.go b/internal/engine/resolution.go index d8b5016..4fcc159 100644 --- a/internal/engine/resolution.go +++ b/internal/engine/resolution.go @@ -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) { diff --git a/internal/engine/resource_context.go b/internal/engine/resource_context.go new file mode 100644 index 0000000..39a9a74 --- /dev/null +++ b/internal/engine/resource_context.go @@ -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) +} diff --git a/internal/engine/resource_context_test.go b/internal/engine/resource_context_test.go new file mode 100644 index 0000000..b2950fb --- /dev/null +++ b/internal/engine/resource_context_test.go @@ -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) + }) + } +} diff --git a/internal/engine/source_context.go b/internal/engine/source_context.go new file mode 100644 index 0000000..2da7c24 --- /dev/null +++ b/internal/engine/source_context.go @@ -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 + } +} diff --git a/resource_context.go b/resource_context.go new file mode 100644 index 0000000..e3315a1 --- /dev/null +++ b/resource_context.go @@ -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 +}