Unify host and native filesystem acquisition
This commit is contained in:
@@ -282,6 +282,16 @@ growth avoids accumulated full-tree copying. Run the everyday checker and
|
|||||||
|
|
||||||
## Stage 3: Unify private filesystem acquisition without public changes
|
## Stage 3: Unify private filesystem acquisition without public changes
|
||||||
|
|
||||||
|
**Completed 2026-09-29.** Imports, glob traversal, and selected-overlay loading
|
||||||
|
now use a private access boundary with the existing contained host backend and
|
||||||
|
a native `fs.FS` backend. Namespace tokens and occurrence identities are separate
|
||||||
|
from labels and paths; repetition tracking is composition-local and input/glob
|
||||||
|
accounting can be shared. Native tests cover slash-relative imports, sorted
|
||||||
|
globs, optional missing paths, root escape, overlays, repeated/cyclic imports,
|
||||||
|
non-comparable filesystems, and handle closure on success and representative
|
||||||
|
failures. Existing host tests, focused native tests, and the everyday checker
|
||||||
|
passed. No public constructor or context field was added.
|
||||||
|
|
||||||
Scope: engine source/file, filesystem, import, glob, and selected-overlay code.
|
Scope: engine source/file, filesystem, import, glob, and selected-overlay code.
|
||||||
|
|
||||||
1. Extract a small private namespace/access boundary used by imports and overlays.
|
1. Extract a small private namespace/access boundary used by imports and overlays.
|
||||||
|
|||||||
184
internal/engine/filesystem_access.go
Normal file
184
internal/engine/filesystem_access.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// namespaceToken identifies one caller-selected filesystem without comparing
|
||||||
|
// arbitrary fs.FS values. A token can be shared by copies of a source wrapper.
|
||||||
|
type namespaceToken struct{ marker byte }
|
||||||
|
|
||||||
|
type nativeNamespace struct {
|
||||||
|
filesystem fs.FS
|
||||||
|
label string
|
||||||
|
token *namespaceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNativeNamespace(label string, filesystem fs.FS) *nativeNamespace {
|
||||||
|
return &nativeNamespace{filesystem: filesystem, label: label, token: &namespaceToken{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceIdentity struct {
|
||||||
|
namespace *namespaceToken
|
||||||
|
path string
|
||||||
|
occurrence *namespaceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesystemAccess is a load-scoped view of either a contained host root or a
|
||||||
|
// borrowed native filesystem. Names are canonical host paths or fs.FS paths.
|
||||||
|
type filesystemAccess struct {
|
||||||
|
rootPath string
|
||||||
|
root *os.Root
|
||||||
|
native fs.FS
|
||||||
|
token *namespaceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func openHostAccess(rootPath string) (*filesystemAccess, error) {
|
||||||
|
root, err := os.OpenRoot(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &filesystemAccess{rootPath: rootPath, root: root, token: &namespaceToken{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNativeAccess(filesystem fs.FS, root string, token *namespaceToken) *filesystemAccess {
|
||||||
|
return &filesystemAccess{rootPath: root, native: filesystem, token: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) close() error {
|
||||||
|
if a.root != nil {
|
||||||
|
return a.root.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) identity(name string) resourceIdentity {
|
||||||
|
return resourceIdentity{namespace: a.token, path: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) syntheticIdentity() resourceIdentity {
|
||||||
|
return resourceIdentity{namespace: a.token, occurrence: &namespaceToken{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) join(base, name string) string {
|
||||||
|
if a.native != nil {
|
||||||
|
return path.Join(base, name)
|
||||||
|
}
|
||||||
|
return filepath.Join(base, filepath.FromSlash(path.Clean(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) dir(name string) string {
|
||||||
|
if a.native != nil {
|
||||||
|
return path.Dir(name)
|
||||||
|
}
|
||||||
|
return filepath.Dir(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) contains(name string) bool {
|
||||||
|
if a.native == nil {
|
||||||
|
return containedPath(a.rootPath, filepath.Clean(name))
|
||||||
|
}
|
||||||
|
if !fs.ValidPath(name) || !fs.ValidPath(a.rootPath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.rootPath == "." || name == a.rootPath || strings.HasPrefix(name, a.rootPath+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) canonical(name string) (string, error) {
|
||||||
|
if a.native == nil {
|
||||||
|
return canonicalExistingPath(name)
|
||||||
|
}
|
||||||
|
if !fs.ValidPath(name) {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
_, err := fs.Stat(a.native, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) relative(name string) (string, error) {
|
||||||
|
if a.native != nil {
|
||||||
|
if !a.contains(name) {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(a.rootPath, name)
|
||||||
|
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
return relative, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) stat(name string) (fs.FileInfo, error) {
|
||||||
|
if a.native != nil {
|
||||||
|
return fs.Stat(a.native, name)
|
||||||
|
}
|
||||||
|
relative, err := a.relative(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a.root.Stat(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) open(name string) (fs.File, error) {
|
||||||
|
if a.native != nil {
|
||||||
|
return a.native.Open(name)
|
||||||
|
}
|
||||||
|
relative, err := a.relative(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a.root.Open(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
|
||||||
|
file, err := a.open(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reader, ok := file.(fs.ReadDirFile); ok {
|
||||||
|
for {
|
||||||
|
entries, readErr := reader.ReadDir(globReadBatch)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !visit(entry) {
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
_ = file.Close()
|
||||||
|
return io.ErrNoProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// fs.ReadDir provides the standard fallback for implementations whose opened
|
||||||
|
// files do not implement fs.ReadDirFile. It may allocate all entries itself.
|
||||||
|
entries, err := fs.ReadDir(a.native, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !visit(entry) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -2,11 +2,8 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -42,29 +39,29 @@ func (s *importTraversal) expandImportGlob(basePath string, declaration importDe
|
|||||||
names = []string{literal}
|
names = []string{literal}
|
||||||
}
|
}
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
absolute := filepath.Join(candidate.absolute, filepath.FromSlash(name))
|
absolute := s.access.join(candidate.absolute, name)
|
||||||
relative := path.Join(candidate.relative, name)
|
relative := path.Join(candidate.relative, name)
|
||||||
if !containedPath(s.rootPath, filepath.Clean(absolute)) {
|
if !s.access.contains(absolute) {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
canonical, err := canonicalExistingPath(absolute)
|
canonical, err := s.access.canonical(absolute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
||||||
}
|
}
|
||||||
if !containedPath(s.rootPath, canonical) {
|
if !s.access.contains(canonical) {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
if last {
|
if last {
|
||||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||||
if len(s.sources)+len(next) > s.limits.sources {
|
if s.accounting.sources+len(next) > s.limits.sources {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, err := os.Stat(canonical)
|
info, err := s.access.stat(canonical)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
||||||
}
|
}
|
||||||
@@ -88,56 +85,47 @@ func (s *importTraversal) expandImportGlob(basePath string, declaration importDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
||||||
canonical, err := canonicalExistingPath(directory)
|
canonical, err := s.access.canonical(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||||
}
|
}
|
||||||
if !containedPath(s.rootPath, canonical) {
|
if !s.access.contains(canonical) {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
relative, err := filepath.Rel(s.rootPath, canonical)
|
if _, err := s.access.relative(canonical); err != nil {
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
directoryFile, err := s.root.Open(relative)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be opened", location)}
|
|
||||||
}
|
|
||||||
|
|
||||||
var matches []string
|
var matches []string
|
||||||
for {
|
var problem *Diagnostic
|
||||||
entries, readErr := directoryFile.ReadDir(globReadBatch)
|
err = s.access.readDirectory(canonical, func(entry fs.DirEntry) bool {
|
||||||
for _, entry := range entries {
|
s.accounting.entries++
|
||||||
s.usedEntries++
|
if s.accounting.entries > s.limits.entries {
|
||||||
if s.usedEntries > s.limits.entries {
|
value := importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)
|
||||||
_ = directoryFile.Close()
|
problem = &value
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
return false
|
||||||
}
|
|
||||||
matched, matchErr := path.Match(pattern, entry.Name())
|
|
||||||
if matchErr != nil {
|
|
||||||
_ = directoryFile.Close()
|
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
|
|
||||||
}
|
|
||||||
if matched {
|
|
||||||
matches = append(matches, entry.Name())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if errors.Is(readErr, io.EOF) {
|
matched, matchErr := path.Match(pattern, entry.Name())
|
||||||
break
|
if matchErr != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)
|
||||||
|
problem = &value
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if readErr != nil {
|
if matched {
|
||||||
_ = directoryFile.Close()
|
matches = append(matches, entry.Name())
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if problem != nil {
|
||||||
|
return nil, []Diagnostic{*problem}
|
||||||
}
|
}
|
||||||
if err := directoryFile.Close(); err != nil {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
|
||||||
}
|
}
|
||||||
slices.Sort(matches)
|
slices.Sort(matches)
|
||||||
return matches, nil
|
return matches, nil
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -40,57 +37,83 @@ type importRoute struct {
|
|||||||
locations []Location
|
locations []Location
|
||||||
}
|
}
|
||||||
|
|
||||||
type importTraversal struct {
|
type importAccounting struct {
|
||||||
rootPath string
|
sources int
|
||||||
root *os.Root
|
bytes int64
|
||||||
limits importLimits
|
nodes int
|
||||||
rule *VersionRule
|
entries int
|
||||||
|
}
|
||||||
|
|
||||||
documents []*document.Node
|
type importTraversal struct {
|
||||||
sources []SourceInfo
|
access *filesystemAccess
|
||||||
active map[string]int
|
accounting *importAccounting
|
||||||
visited map[string]importRoute
|
limits importLimits
|
||||||
stack []importRoute
|
rule *VersionRule
|
||||||
usedBytes int64
|
|
||||||
usedNodes int
|
documents []*document.Node
|
||||||
usedEntries int
|
sources []SourceInfo
|
||||||
|
active map[resourceIdentity]int
|
||||||
|
visited map[resourceIdentity]importRoute
|
||||||
|
stack []importRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
if source.kind == sourceFilesystem {
|
||||||
|
access := newNativeAccess(source.namespace.filesystem, ".", source.namespace.token)
|
||||||
|
return loadImportedWithAccess(access, access.dir(source.path), source.path, main, rule, limits, &importAccounting{})
|
||||||
|
}
|
||||||
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
root, err := os.OpenRoot(rootPath)
|
access, err := openHostAccess(rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
diagnostic.Primary = nil
|
diagnostic.Primary = nil
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
|
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
|
||||||
}
|
}
|
||||||
|
return loadImportedWithAccess(access, basePath, mainPath, main, rule, limits, &importAccounting{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
||||||
|
_ = access.close()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
|
||||||
|
}
|
||||||
|
for _, directory := range []string{access.rootPath, basePath} {
|
||||||
|
info, err := access.stat(directory)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
_ = access.close()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)}
|
||||||
|
}
|
||||||
|
}
|
||||||
mainInfo := cloneSourceInfo(*main.source)
|
mainInfo := cloneSourceInfo(*main.source)
|
||||||
if mainPath != "" {
|
if mainPath != "" {
|
||||||
mainInfo.Path = mainPath
|
mainInfo.Path = mainPath
|
||||||
}
|
}
|
||||||
|
accounting.sources++
|
||||||
|
accounting.bytes += main.bytes
|
||||||
|
accounting.nodes += countInputNodes(main.document)
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
rootPath: rootPath, root: root, limits: limits, rule: rule,
|
access: access, accounting: accounting, limits: limits, rule: rule,
|
||||||
documents: make([]*document.Node, 0, 1),
|
documents: make([]*document.Node, 0, 1),
|
||||||
sources: []SourceInfo{mainInfo},
|
sources: []SourceInfo{mainInfo},
|
||||||
active: make(map[string]int), visited: make(map[string]importRoute),
|
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
|
||||||
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
|
|
||||||
}
|
}
|
||||||
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
||||||
_ = root.Close()
|
_ = access.close()
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
|
|
||||||
mainIdentity := mainPath
|
mainIdentity := mainPath
|
||||||
|
mainKey := access.identity(mainPath)
|
||||||
if mainIdentity == "" {
|
if mainIdentity == "" {
|
||||||
mainIdentity = "<selected-source>"
|
mainIdentity = "<selected-source>"
|
||||||
|
mainKey = access.syntheticIdentity()
|
||||||
}
|
}
|
||||||
mainRoute := importRoute{canonical: mainIdentity}
|
mainRoute := importRoute{canonical: mainIdentity}
|
||||||
state.active[mainIdentity] = 0
|
state.active[mainKey] = 0
|
||||||
state.visited[mainIdentity] = mainRoute
|
state.visited[mainKey] = mainRoute
|
||||||
state.stack = append(state.stack, mainRoute)
|
state.stack = append(state.stack, mainRoute)
|
||||||
body, declarations, diagnostics := splitImportDirectives(main.document)
|
body, declarations, diagnostics := splitImportDirectives(main.document)
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
@@ -98,7 +121,7 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
closeErr := root.Close()
|
closeErr := access.close()
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, cloneSourceInfos(state.sources), diagnostics
|
return nil, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
@@ -107,22 +130,22 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
}
|
}
|
||||||
composed, diagnostics := composeDocuments(state.documents...)
|
composed, diagnostics := composeDocuments(state.documents...)
|
||||||
if composed != nil {
|
if composed != nil {
|
||||||
composed.inputBytes = state.usedBytes
|
composed.inputBytes = accounting.bytes
|
||||||
composed.inputNodes = state.usedNodes
|
composed.inputNodes = accounting.nodes
|
||||||
}
|
}
|
||||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||||
if s.limits.sources < 1 {
|
if s.accounting.sources > s.limits.sources {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
if s.usedBytes > s.limits.bytes {
|
if s.accounting.bytes > s.limits.bytes {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
if s.usedNodes > s.limits.nodes {
|
if s.accounting.nodes > s.limits.nodes {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
@@ -169,11 +192,11 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate := filepath.Join(basePath, filepath.FromSlash(path.Clean(declaration.path)))
|
candidate := s.access.join(basePath, declaration.path)
|
||||||
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
|
if !s.access.contains(candidate) {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
canonical, err := canonicalExistingPath(candidate)
|
canonical, err := s.access.canonical(candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil
|
return nil
|
||||||
@@ -187,30 +210,30 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
}
|
}
|
||||||
return []Diagnostic{importDiagnostic(code, message, &location)}
|
return []Diagnostic{importDiagnostic(code, message, &location)}
|
||||||
}
|
}
|
||||||
if !containedPath(s.rootPath, canonical) {
|
if !s.access.contains(canonical) {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentRoute := s.currentRoute(location)
|
currentRoute := s.currentRoute(location)
|
||||||
if activeIndex, ok := s.active[canonical]; ok {
|
identity := s.access.identity(canonical)
|
||||||
|
if activeIndex, ok := s.active[identity]; ok {
|
||||||
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
||||||
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
||||||
}
|
}
|
||||||
if previous, ok := s.visited[canonical]; ok {
|
if previous, ok := s.visited[identity]; ok {
|
||||||
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
||||||
}
|
}
|
||||||
if len(s.sources)+1 > s.limits.sources {
|
if s.accounting.sources+1 > s.limits.sources {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
||||||
}
|
}
|
||||||
|
|
||||||
relative, err := filepath.Rel(s.rootPath, canonical)
|
if _, err := s.access.relative(canonical); err != nil {
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
|
sourceID := "source-" + strconv.Itoa(s.accounting.sources+1)
|
||||||
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
|
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
|
||||||
s.sources = append(s.sources, info)
|
s.sources = append(s.sources, info)
|
||||||
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
|
data, skipped, diagnostic := s.readImport(canonical, sourceID, declaration.optional)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return []Diagnostic{*diagnostic}
|
return []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
@@ -218,6 +241,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
s.sources = s.sources[:len(s.sources)-1]
|
s.sources = s.sources[:len(s.sources)-1]
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
s.accounting.sources++
|
||||||
|
|
||||||
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
||||||
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||||
@@ -226,10 +250,10 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
return parserDiagnostics(sourceID, issues)
|
return parserDiagnostics(sourceID, issues)
|
||||||
}
|
}
|
||||||
nodeCount := countInputNodes(root)
|
nodeCount := countInputNodes(root)
|
||||||
if s.usedNodes+nodeCount > s.limits.nodes {
|
if s.accounting.nodes+nodeCount > s.limits.nodes {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||||
}
|
}
|
||||||
s.usedNodes += nodeCount
|
s.accounting.nodes += nodeCount
|
||||||
|
|
||||||
body, declarations, diagnostics := splitImportDirectives(root)
|
body, declarations, diagnostics := splitImportDirectives(root)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -245,22 +269,22 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
|
|
||||||
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
||||||
s.documents = append(s.documents, body)
|
s.documents = append(s.documents, body)
|
||||||
s.active[canonical] = len(s.stack)
|
s.active[identity] = len(s.stack)
|
||||||
s.visited[canonical] = route
|
s.visited[identity] = route
|
||||||
s.stack = append(s.stack, route)
|
s.stack = append(s.stack, route)
|
||||||
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
|
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth)
|
||||||
delete(s.active, canonical)
|
delete(s.active, identity)
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
||||||
remaining := s.limits.bytes - s.usedBytes
|
remaining := s.limits.bytes - s.accounting.bytes
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
if optional && failure.missingTarget {
|
if optional && failure.missingTarget {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
@@ -268,7 +292,7 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
|
|||||||
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
return nil, false, &value
|
return nil, false, &value
|
||||||
}
|
}
|
||||||
s.usedBytes += int64(len(data))
|
s.accounting.bytes += int64(len(data))
|
||||||
return data, false, nil
|
return data, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
284
internal/engine/native_filesystem_test.go
Normal file
284
internal/engine/native_filesystem_test.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeFilesystemImportsAndOverlay(t *testing.T) {
|
||||||
|
filesystem := fstest.MapFS{
|
||||||
|
"config/main.yml": &fstest.MapFile{Data: []byte(`configkit:
|
||||||
|
imports:
|
||||||
|
- path: ../shared.yml
|
||||||
|
- glob: fragments/*.yml
|
||||||
|
items: {main: main}
|
||||||
|
`)},
|
||||||
|
"shared.yml": &fstest.MapFile{Data: []byte("items: {shared: shared}\n")},
|
||||||
|
"config/fragments/b.yml": &fstest.MapFile{Data: []byte("items: {beta: beta}\n")},
|
||||||
|
"config/fragments/a.yml": &fstest.MapFile{Data: []byte("items: {alpha: alpha}\n")},
|
||||||
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {main: overlaid}\n")},
|
||||||
|
}
|
||||||
|
namespace := newNativeNamespace("bundle", filesystem)
|
||||||
|
result, err := testLoad[importTestConfig](filesystemFile(namespace, "config/main.yml"), Options[importTestConfig]{Imports: true, Overlay: "testing"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{"main": "overlaid", "shared": "shared", "alpha": "alpha", "beta": "beta"}
|
||||||
|
if !reflect.DeepEqual(result.Config.Items, want) {
|
||||||
|
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
wantPaths := []string{"config/main.yml", "shared.yml", "config/fragments/a.yml", "config/fragments/b.yml", "overlays/testing.yml"}
|
||||||
|
gotPaths := make([]string, len(sources))
|
||||||
|
for index, source := range sources {
|
||||||
|
gotPaths[index] = source.Path
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotPaths, wantPaths) {
|
||||||
|
t.Fatalf("source paths = %#v, want %#v", gotPaths, wantPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeFilesystemNamespaceAndPathFailures(t *testing.T) {
|
||||||
|
first := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: first}\n")}}
|
||||||
|
second := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: second}\n")}}
|
||||||
|
firstNamespace := newNativeNamespace("same", first)
|
||||||
|
secondNamespace := newNativeNamespace("same", second)
|
||||||
|
if reflect.TypeOf(first).Comparable() {
|
||||||
|
t.Fatal("test filesystem must have a non-comparable dynamic value")
|
||||||
|
}
|
||||||
|
if firstNamespace.token == secondNamespace.token {
|
||||||
|
t.Fatal("independent filesystem constructors shared identity")
|
||||||
|
}
|
||||||
|
if newNativeAccess(first, ".", firstNamespace.token).identity("main.yml") == newNativeAccess(second, ".", secondNamespace.token).identity("main.yml") {
|
||||||
|
t.Fatal("equal paths from separate filesystems have the same identity")
|
||||||
|
}
|
||||||
|
access := newNativeAccess(first, ".", firstNamespace.token)
|
||||||
|
if access.syntheticIdentity() == access.syntheticIdentity() || access.syntheticIdentity() == access.identity("<selected-source>") {
|
||||||
|
t.Fatal("independent synthetic sources shared a file identity")
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
namespace *nativeNamespace
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "first", namespace: firstNamespace, want: "first"},
|
||||||
|
{name: "second", namespace: secondNamespace, want: "second"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := testLoad[importTestConfig](filesystemFile(test.namespace, "main.yml"))
|
||||||
|
if err != nil || result.Config.Items["name"] != test.want {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, name := range []string{".", "../main.yml", "/main.yml", "missing.yml"} {
|
||||||
|
_, diagnostics := readSource(filesystemFile(firstNamespace, name), "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 1 {
|
||||||
|
t.Errorf("%q diagnostics = %#v", name, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missingImport := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: host-only.yml}]}\n")}}
|
||||||
|
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", missingImport), "main.yml"), Options[importTestConfig]{Imports: true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("missing virtual import fell back to the host filesystem")
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
|
t.Fatalf("missing import error = %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeFilesystemClosesFilesOnSuccessAndFailure(t *testing.T) {
|
||||||
|
for _, fallback := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "ReadDirFile", true: "ReadDir fallback"}[fallback], func(t *testing.T) {
|
||||||
|
filesystem := &observedFilesystem{files: fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{glob: 'parts/*.yml'}]}\nitems: {main: main}\n")},
|
||||||
|
"parts/one.yml": &fstest.MapFile{Data: []byte("items: {one: one}\n")},
|
||||||
|
}, fallback: fallback}
|
||||||
|
result, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[importTestConfig]{Imports: true})
|
||||||
|
if err != nil || result.Config.Items["one"] != "one" {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
filesystem.assertClosed(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, failure := range []struct {
|
||||||
|
name string
|
||||||
|
file string
|
||||||
|
failRead string
|
||||||
|
failStat string
|
||||||
|
failDir string
|
||||||
|
}{
|
||||||
|
{name: "read", file: "items: {one: one}\n", failRead: "parts/one.yml"},
|
||||||
|
{name: "stat", file: "items: {one: one}\n", failStat: "parts/one.yml"},
|
||||||
|
{name: "parse", file: "items: [unterminated\n"},
|
||||||
|
{name: "directory", file: "items: {one: one}\n", failDir: "parts"},
|
||||||
|
} {
|
||||||
|
t.Run(failure.name, func(t *testing.T) {
|
||||||
|
filesystem := &observedFilesystem{files: fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{glob: 'parts/*.yml'}]}\n")},
|
||||||
|
"parts/one.yml": &fstest.MapFile{Data: []byte(failure.file)},
|
||||||
|
}, failRead: failure.failRead, failStat: failure.failStat, failDir: failure.failDir}
|
||||||
|
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[importTestConfig]{Imports: true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a load failure")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "tracking failure canary") {
|
||||||
|
t.Fatal("unsafe filesystem error escaped")
|
||||||
|
}
|
||||||
|
filesystem.assertClosed(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
|
||||||
|
filesystem := fstest.MapFS{
|
||||||
|
"config/sub/main.yml": &fstest.MapFile{Data: []byte(`configkit:
|
||||||
|
imports:
|
||||||
|
- path: ../shared.yml
|
||||||
|
- path: absent.yml
|
||||||
|
optional: true
|
||||||
|
- glob: absent/*.yml
|
||||||
|
optional: true
|
||||||
|
`)},
|
||||||
|
"config/shared.yml": &fstest.MapFile{Data: []byte("items: {shared: retained}\n")},
|
||||||
|
"outside.yml": &fstest.MapFile{Data: []byte("items: {outside: blocked}\n")},
|
||||||
|
}
|
||||||
|
namespace := newNativeNamespace("bundle", filesystem)
|
||||||
|
source := filesystemFile(namespace, "config/sub/main.yml")
|
||||||
|
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
access := newNativeAccess(filesystem, "config", namespace.token)
|
||||||
|
composed, sources, diagnostics := loadImportedWithAccess(access, "config/sub", source.path, parsed, nil, defaultImportLimits(), &importAccounting{})
|
||||||
|
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 || sources[1].Path != "config/shared.yml" {
|
||||||
|
t.Fatalf("contained imports = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
outside := fstest.MapFS{
|
||||||
|
"config/sub/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../../outside.yml}]}\n")},
|
||||||
|
"outside.yml": &fstest.MapFile{Data: []byte("items: {outside: blocked}\n")},
|
||||||
|
}
|
||||||
|
outsideNamespace := newNativeNamespace("bundle", outside)
|
||||||
|
parsed, diagnostics = parseSource(filesystemFile(outsideNamespace, "config/sub/main.yml"), "source-1", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
_, _, diagnostics = loadImportedWithAccess(newNativeAccess(outside, "config", outsideNamespace.token), "config/sub", "config/sub/main.yml", parsed, nil, defaultImportLimits(), &importAccounting{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||||
|
t.Fatalf("escaping import diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeFilesystemDetectsRepeatedAndCyclicImports(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
files fstest.MapFS
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "repeated",
|
||||||
|
files: fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: child.yml}, {path: child.yml}]}\n")},
|
||||||
|
"child.yml": &fstest.MapFile{Data: []byte("items: {child: child}\n")},
|
||||||
|
},
|
||||||
|
code: CodeImportRepeated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle",
|
||||||
|
files: fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: child.yml}]}\n")},
|
||||||
|
"child.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: main.yml}]}\n")},
|
||||||
|
},
|
||||||
|
code: CodeImportCycle,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", test.files), "main.yml"), Options[importTestConfig]{Imports: true})
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.code {
|
||||||
|
t.Fatalf("load error = %#v, want %q", err, test.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type observedFilesystem struct {
|
||||||
|
files fstest.MapFS
|
||||||
|
opened int
|
||||||
|
closed int
|
||||||
|
fallback bool
|
||||||
|
failRead string
|
||||||
|
failStat string
|
||||||
|
failDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedFilesystem) Open(name string) (fs.File, error) {
|
||||||
|
inner, err := f.files.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.opened++
|
||||||
|
file := &observedFile{File: inner, owner: f, name: name}
|
||||||
|
if directory, ok := inner.(fs.ReadDirFile); ok && !f.fallback {
|
||||||
|
return &observedDirectoryFile{observedFile: file, directory: directory}, nil
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedFilesystem) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||||
|
if name == f.failDir {
|
||||||
|
return nil, errors.New("tracking failure canary")
|
||||||
|
}
|
||||||
|
return fs.ReadDir(f.files, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedFilesystem) assertClosed(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if f.opened == 0 || f.opened != f.closed {
|
||||||
|
t.Fatalf("filesystem opens=%d closes=%d", f.opened, f.closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type observedFile struct {
|
||||||
|
fs.File
|
||||||
|
owner *observedFilesystem
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedFile) Read(data []byte) (int, error) {
|
||||||
|
if f.name == f.owner.failRead {
|
||||||
|
return 0, errors.New("tracking failure canary")
|
||||||
|
}
|
||||||
|
return f.File.Read(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedFile) Stat() (fs.FileInfo, error) {
|
||||||
|
if f.name == f.owner.failStat {
|
||||||
|
return nil, errors.New("tracking failure canary")
|
||||||
|
}
|
||||||
|
return f.File.Stat()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedFile) Close() error {
|
||||||
|
f.owner.closed++
|
||||||
|
return f.File.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type observedDirectoryFile struct {
|
||||||
|
*observedFile
|
||||||
|
directory fs.ReadDirFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedDirectoryFile) ReadDir(count int) ([]fs.DirEntry, error) {
|
||||||
|
if f.name == f.owner.failDir {
|
||||||
|
return nil, errors.New("tracking failure canary")
|
||||||
|
}
|
||||||
|
return f.directory.ReadDir(count)
|
||||||
|
}
|
||||||
@@ -4,10 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
)
|
)
|
||||||
@@ -41,12 +38,32 @@ func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
||||||
|
if source.kind == sourceFilesystem {
|
||||||
|
access := newNativeAccess(source.namespace.filesystem, ".", source.namespace.token)
|
||||||
|
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits)
|
||||||
|
}
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
overlayPath := filepath.Join(rootPath, "overlays", name+".yml")
|
access, err := openHostAccess(rootPath)
|
||||||
canonical, err := canonicalExistingPath(overlayPath)
|
if err != nil {
|
||||||
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
||||||
|
value.Primary = nil
|
||||||
|
return cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
|
}
|
||||||
|
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
||||||
|
closed := false
|
||||||
|
defer func() {
|
||||||
|
if !closed {
|
||||||
|
_ = access.close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
overlayPath := access.join(access.rootPath, "overlays/"+name+".yml")
|
||||||
|
canonical, err := access.canonical(overlayPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code := CodeOverlayInvalid
|
code := CodeOverlayInvalid
|
||||||
message := "selected overlay could not be resolved"
|
message := "selected overlay could not be resolved"
|
||||||
@@ -57,7 +74,7 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
|||||||
}
|
}
|
||||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
|
||||||
}
|
}
|
||||||
if !containedPath(rootPath, canonical) {
|
if !access.contains(canonical) {
|
||||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
||||||
}
|
}
|
||||||
for _, existing := range sources {
|
for _, existing := range sources {
|
||||||
@@ -69,28 +86,15 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
|||||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
root, err := os.OpenRoot(rootPath)
|
if _, err := access.relative(canonical); err != nil {
|
||||||
if err != nil {
|
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
|
||||||
value.Primary = nil
|
|
||||||
return cloneSourceInfos(sources), []Diagnostic{value}
|
|
||||||
}
|
|
||||||
rootClosed := false
|
|
||||||
defer func() {
|
|
||||||
if !rootClosed {
|
|
||||||
_ = root.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
relative, err := filepath.Rel(rootPath, canonical)
|
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay could not be addressed inside the configuration root", nil)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay could not be addressed inside the configuration root", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
||||||
overlayLocation := &Location{SourceID: sourceID}
|
overlayLocation := &Location{SourceID: sourceID}
|
||||||
overlaySource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("overlays", name+".yml")), Path: canonical, Layer: LayerOverlay}
|
overlaySource := SourceInfo{ID: sourceID, Label: "overlays/" + name + ".yml", Path: canonical, Layer: LayerOverlay}
|
||||||
sources = append(cloneSourceInfos(sources), overlaySource)
|
sources = append(cloneSourceInfos(sources), overlaySource)
|
||||||
data, diagnostic := readOverlayFile(root, relative, sourceID, limits.bytes-base.current.inputBytes)
|
data, diagnostic := readOverlayFile(access, canonical, sourceID, limits.bytes-base.current.inputBytes)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return sources, []Diagnostic{*diagnostic}
|
return sources, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
@@ -112,8 +116,8 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
|||||||
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
|
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
closeErr := root.Close()
|
closeErr := access.close()
|
||||||
rootClosed = true
|
closed = true
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
||||||
}
|
}
|
||||||
@@ -126,12 +130,12 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
|||||||
return sources, nil
|
return sources, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readOverlayFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
func readOverlayFile(access *filesystemAccess, name, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
data, failure := readRegularFile(name, access.stat, access.open, limit)
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
value := sourceReadDiagnostic(failure, sourceID, CodeOverlayLimitExceeded, "selected overlay exceeds a source or aggregate byte limit")
|
value := sourceReadDiagnostic(failure, sourceID, CodeOverlayLimitExceeded, "selected overlay exceeds a source or aggregate byte limit")
|
||||||
return nil, &value
|
return nil, &value
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Source struct {
|
|||||||
kind sourceKind
|
kind sourceKind
|
||||||
path string
|
path string
|
||||||
label string
|
label string
|
||||||
|
namespace *nativeNamespace
|
||||||
data []byte
|
data []byte
|
||||||
reader *readerSource
|
reader *readerSource
|
||||||
createErr sourceCreationError
|
createErr sourceCreationError
|
||||||
@@ -32,6 +33,7 @@ type sourceKind uint8
|
|||||||
const (
|
const (
|
||||||
sourceInvalid sourceKind = iota
|
sourceInvalid sourceKind = iota
|
||||||
sourceFile
|
sourceFile
|
||||||
|
sourceFilesystem
|
||||||
sourceBytes
|
sourceBytes
|
||||||
sourceReader
|
sourceReader
|
||||||
sourceNone
|
sourceNone
|
||||||
@@ -60,6 +62,13 @@ func File(path string) Source {
|
|||||||
return Source{kind: sourceFile, path: path, label: path}
|
return Source{kind: sourceFile, path: path, label: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filesystemFile(namespace *nativeNamespace, name string) Source {
|
||||||
|
if namespace == nil || namespace.filesystem == nil || name == "." || !fs.ValidPath(name) {
|
||||||
|
return Source{kind: sourceFilesystem, createErr: creationInvalidPath}
|
||||||
|
}
|
||||||
|
return Source{kind: sourceFilesystem, path: name, label: namespace.label + "/" + name, namespace: namespace}
|
||||||
|
}
|
||||||
|
|
||||||
// Bytes snapshots data immediately. Label must be nonempty and safe to display;
|
// Bytes snapshots data immediately. Label must be nonempty and safe to display;
|
||||||
// constructor problems are reported when the source is consumed.
|
// constructor problems are reported when the source is consumed.
|
||||||
func Bytes(label string, data []byte) Source {
|
func Bytes(label string, data []byte) Source {
|
||||||
@@ -143,6 +152,18 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||||
}
|
}
|
||||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||||
|
case sourceFilesystem:
|
||||||
|
access := newNativeAccess(source.namespace.filesystem, ".", source.namespace.token)
|
||||||
|
info.Path = source.path
|
||||||
|
canonical, err := access.canonical(source.path)
|
||||||
|
if err != nil {
|
||||||
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||||
|
}
|
||||||
|
data, failure := readRegularFile(canonical, access.stat, access.open, limit)
|
||||||
|
if failure != nil {
|
||||||
|
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||||
|
}
|
||||||
|
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||||
case sourceBytes:
|
case sourceBytes:
|
||||||
input = bytes.NewReader(source.data)
|
input = bytes.NewReader(source.data)
|
||||||
case sourceReader:
|
case sourceReader:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceReadFailureKind uint8
|
type sourceReadFailureKind uint8
|
||||||
@@ -26,7 +25,7 @@ type sourceReadFailure struct {
|
|||||||
// readRegularFile borrows stat/open operations so root-bound callers retain their
|
// readRegularFile borrows stat/open operations so root-bound callers retain their
|
||||||
// containment policy. Preflight rejects FIFOs before open can block. The opened
|
// containment policy. Preflight rejects FIFOs before open can block. The opened
|
||||||
// handle is checked again and always closed; read-only close errors are ignored.
|
// handle is checked again and always closed; read-only close errors are ignored.
|
||||||
func readRegularFile(name string, stat func(string) (fs.FileInfo, error), open func(string) (*os.File, error), limit int64) ([]byte, *sourceReadFailure) {
|
func readRegularFile[F fs.File](name string, stat func(string) (fs.FileInfo, error), open func(string) (F, error), limit int64) ([]byte, *sourceReadFailure) {
|
||||||
info, err := stat(name)
|
info, err := stat(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
||||||
|
|||||||
Reference in New Issue
Block a user