diff --git a/docs/config.md b/docs/config.md index 2a4cfa8..e735fc0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1529,6 +1529,10 @@ base import graph is composed and its schema version is checked, but before defa and typed decoding. Overlay bytes, nodes, and source records count toward the same aggregate limits as base inputs. Its canonical path must remain within the configured root and must not duplicate a file already present in the base graph. +For native filesystems that implement `fs.ReadLinkFS`, these checks resolve +link targets in the selected namespace. For other native filesystems, the +checks use namespace-relative paths and reads follow that filesystem's +ordinary behavior. Collection destinations follow the [collection operation and presence rules](#strict-document-composition) above. diff --git a/facade_main_relative_test.go b/facade_main_relative_test.go index 6a574bd..eea2693 100644 --- a/facade_main_relative_test.go +++ b/facade_main_relative_test.go @@ -218,3 +218,43 @@ func TestLayeredOverlayNativeContext(t *testing.T) { } t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics()) } + +func TestLayeredOverlayNativeLinks(t *testing.T) { + for _, tc := range []struct { + name, mainPath, target string + want configkit.Code + }{ + {"outside root", "scope/main.yml", "../../outside.yml", configkit.CodeOverlayOutsideRoot}, + {"overlay aliases main", "scope/main.yml", "../main.yml", configkit.CodeOverlayRepeated}, + {"main aliases overlay target", "scope/alias.yml", "../target.yml", configkit.CodeOverlayRepeated}, + {"contained overlay", "scope/main.yml", "../target.yml", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + native := configkit.NewFilesystem("bundle", fstest.MapFS{ + "scope/main.yml": {Data: []byte("count: 1\n")}, + "scope/alias.yml": {Data: []byte("target.yml"), Mode: os.ModeSymlink}, + "scope/target.yml": {Data: []byte("count: 7\n")}, + "scope/overlays/test.yml": {Data: []byte(tc.target), Mode: os.ModeSymlink}, + "outside.yml": {Data: []byte("count: 9\n")}, + }) + main := configkit.Input{Source: native.File(tc.mainPath), Filesystem: &configkit.FilesystemContext{Root: "scope", FS: &native}} + result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"}) + if tc.want != "" { + if err == nil { + t.Fatal("overlay link unexpectedly loaded") + } + diagnostics := err.(*configkit.LoadError).Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != tc.want { + t.Fatalf("diagnostics = %#v, want %v", diagnostics, tc.want) + } + return + } + if err != nil { + t.Fatal(err) + } + if result.Config.Count != 7 || result.Sources()[1].Path != "scope/target.yml" { + t.Fatalf("config = %#v, sources = %#v", result.Config, result.Sources()) + } + }) + } +} diff --git a/internal/engine/selected_overlay.go b/internal/engine/selected_overlay.go index 4f5ba16..62a65ba 100644 --- a/internal/engine/selected_overlay.go +++ b/internal/engine/selected_overlay.go @@ -76,12 +76,21 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces lookupDirectory = access.rootPath } overlayPath := access.join(lookupDirectory, "overlays/"+request.name+".yml") - canonical, err := access.canonical(overlayPath) + links, _ := access.native.(fs.ReadLinkFS) + var canonical string + var err error + if links != nil { + canonical, err = resolveNativeImportPath(access, links, overlayPath) + } else { + canonical, err = access.canonical(overlayPath) + } if err != nil { code := CodeOverlayInvalid message := "selected overlay could not be resolved" if errors.Is(err, fs.ErrNotExist) { code, message = CodeOverlayMissing, "selected overlay does not exist" + } else if errors.Is(err, errImportPathOutsideRoot) { + code, message = CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root" } else if errors.Is(err, fs.ErrPermission) { code, message = CodeImportPermission, "selected overlay cannot be accessed" } @@ -90,12 +99,8 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces if !access.contains(canonical) { return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)} } - for _, existing := range sources { - if existing.Path != "" && existing.Path == canonical && - ((access.native == nil && existing.Kind == SourceKindFile) || - (access.native != nil && existing.Kind == SourceKindFilesystem && existing.namespace == access.token)) { - return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)} - } + if overlayRepeatsSource(access, links, canonical, sources) { + return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)} } if len(sources)+1 > limits.sources { return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)} @@ -174,6 +179,37 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces return sources, nil } +func overlayRepeatsSource(access *filesystemAccess, links fs.ReadLinkFS, canonical string, sources []SourceInfo) bool { + // Other inputs can have a wider root. Compare their identities in the + // shared native namespace while keeping overlay lookup rooted separately. + namespaceAccess := *access + namespaceAccess.rootPath = "." + for _, source := range sources { + if source.Path == "" { + continue + } + if access.native == nil { + if source.Kind == SourceKindFile && source.Path == canonical { + return true + } + continue + } + if source.Kind != SourceKindFilesystem || source.namespace != access.token { + continue + } + if source.Path == canonical { + return true + } + if links != nil { + target, err := resolveNativeImportPath(&namespaceAccess, links, source.Path) + if err == nil && target == canonical { + return true + } + } + } + return false +} + func readOverlayFile(access *filesystemAccess, name, sourceID string, remaining int64) ([]byte, *Diagnostic) { limit := min(int64(maxSourceBytes), remaining) if limit < 0 {