325 lines
11 KiB
Go
325 lines
11 KiB
Go
package configkit
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
|
)
|
|
|
|
const (
|
|
maxImportDepth = 64
|
|
maxImportSources = 1024
|
|
maxGlobEntries = 1_000_000
|
|
)
|
|
|
|
type importLimits struct {
|
|
depth int
|
|
sources int
|
|
bytes int64
|
|
nodes int
|
|
entries int
|
|
}
|
|
|
|
func defaultImportLimits() importLimits {
|
|
return importLimits{
|
|
depth: maxImportDepth, sources: maxImportSources,
|
|
bytes: maxTotalBytes, nodes: maxDocumentNodes, entries: maxGlobEntries,
|
|
}
|
|
}
|
|
|
|
type importRoute struct {
|
|
canonical string
|
|
locations []Location
|
|
}
|
|
|
|
type importTraversal struct {
|
|
rootPath string
|
|
root *os.Root
|
|
limits importLimits
|
|
rule *VersionRule
|
|
|
|
documents []*document.Node
|
|
sources []SourceInfo
|
|
active map[string]int
|
|
visited map[string]importRoute
|
|
stack []importRoute
|
|
usedBytes int64
|
|
usedNodes int
|
|
usedEntries int
|
|
}
|
|
|
|
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
|
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
|
if diagnostic != nil {
|
|
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
|
}
|
|
root, err := os.OpenRoot(rootPath)
|
|
if err != nil {
|
|
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
|
diagnostic.Primary = nil
|
|
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
|
|
}
|
|
|
|
mainInfo := cloneSourceInfo(*main.source)
|
|
if mainPath != "" {
|
|
mainInfo.Path = mainPath
|
|
}
|
|
state := importTraversal{
|
|
rootPath: rootPath, root: root, limits: limits, rule: rule,
|
|
documents: make([]*document.Node, 0, 1),
|
|
sources: []SourceInfo{mainInfo},
|
|
active: make(map[string]int), visited: make(map[string]importRoute),
|
|
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
|
|
}
|
|
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
|
_ = root.Close()
|
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
|
}
|
|
|
|
mainIdentity := mainPath
|
|
if mainIdentity == "" {
|
|
mainIdentity = "<selected-source>"
|
|
}
|
|
mainRoute := importRoute{canonical: mainIdentity}
|
|
state.active[mainIdentity] = 0
|
|
state.visited[mainIdentity] = mainRoute
|
|
state.stack = append(state.stack, mainRoute)
|
|
body, declarations, diagnostics := splitImportDirectives(main.document)
|
|
if len(diagnostics) == 0 {
|
|
state.documents = append(state.documents, body)
|
|
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
|
}
|
|
|
|
closeErr := root.Close()
|
|
if len(diagnostics) != 0 {
|
|
return nil, cloneSourceInfos(state.sources), diagnostics
|
|
}
|
|
if closeErr != nil {
|
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
|
}
|
|
composed, diagnostics := composeDocuments(state.documents...)
|
|
if composed != nil {
|
|
composed.inputBytes = state.usedBytes
|
|
composed.inputNodes = state.usedNodes
|
|
}
|
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
|
}
|
|
|
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
|
if s.limits.sources < 1 {
|
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
|
return &value
|
|
}
|
|
if s.usedBytes > s.limits.bytes {
|
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
|
return &value
|
|
}
|
|
if s.usedNodes > s.limits.nodes {
|
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
|
return &value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic {
|
|
for _, declaration := range declarations {
|
|
if declaration.glob != "" {
|
|
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
|
if len(diagnostics) != 0 {
|
|
return diagnostics
|
|
}
|
|
if len(matches) == 0 {
|
|
if declaration.optional {
|
|
continue
|
|
}
|
|
location := declaration.location
|
|
return []Diagnostic{importDiagnostic(CodeImportMissing, "required import glob matched no files", &location)}
|
|
}
|
|
for _, match := range matches {
|
|
matched := declaration
|
|
matched.path = match
|
|
matched.glob = ""
|
|
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 {
|
|
return diagnostics
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 {
|
|
return diagnostics
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic {
|
|
location := declaration.location
|
|
if depth > s.limits.depth {
|
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
|
}
|
|
if !validImportPath(declaration.path) {
|
|
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)))
|
|
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
|
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
|
}
|
|
canonical, err := canonicalExistingPath(candidate)
|
|
if err != nil {
|
|
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
code := CodeImportInvalid
|
|
message := "import target could not be canonicalized"
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
code, message = CodeImportMissing, "required import target does not exist"
|
|
} else if errors.Is(err, fs.ErrPermission) {
|
|
message = "import target cannot be accessed"
|
|
}
|
|
return []Diagnostic{importDiagnostic(code, message, &location)}
|
|
}
|
|
if !containedPath(s.rootPath, canonical) {
|
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
|
}
|
|
|
|
currentRoute := s.currentRoute(location)
|
|
if activeIndex, ok := s.active[canonical]; ok {
|
|
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
|
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
|
}
|
|
if previous, ok := s.visited[canonical]; ok {
|
|
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
|
}
|
|
if len(s.sources)+1 > s.limits.sources {
|
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
|
}
|
|
|
|
relative, err := filepath.Rel(s.rootPath, canonical)
|
|
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)}
|
|
}
|
|
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
|
|
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
|
|
s.sources = append(s.sources, info)
|
|
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
|
|
if diagnostic != nil {
|
|
return []Diagnostic{*diagnostic}
|
|
}
|
|
if skipped {
|
|
s.sources = s.sources[:len(s.sources)-1]
|
|
return nil
|
|
}
|
|
|
|
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
|
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
|
})
|
|
if len(issues) != 0 {
|
|
return parserDiagnostics(sourceID, issues)
|
|
}
|
|
nodeCount := countInputNodes(root)
|
|
if s.usedNodes+nodeCount > s.limits.nodes {
|
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
|
}
|
|
s.usedNodes += nodeCount
|
|
|
|
body, declarations, diagnostics := splitImportDirectives(root)
|
|
if len(diagnostics) != 0 {
|
|
return diagnostics
|
|
}
|
|
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
|
|
versionLocation := publicLocation(versionNode.Location)
|
|
return []Diagnostic{{
|
|
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
|
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
|
|
}}
|
|
}
|
|
|
|
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
|
s.documents = append(s.documents, body)
|
|
s.active[canonical] = len(s.stack)
|
|
s.visited[canonical] = route
|
|
s.stack = append(s.stack, route)
|
|
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
|
|
delete(s.active, canonical)
|
|
s.stack = s.stack[:len(s.stack)-1]
|
|
return diagnostics
|
|
}
|
|
|
|
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
|
remaining := s.limits.bytes - s.usedBytes
|
|
limit := min(int64(maxSourceBytes), remaining)
|
|
if limit < 0 {
|
|
limit = 0
|
|
}
|
|
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
|
if failure != nil {
|
|
if optional && failure.missingTarget {
|
|
return nil, true, nil
|
|
}
|
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
|
return nil, false, &value
|
|
}
|
|
s.usedBytes += int64(len(data))
|
|
return data, false, nil
|
|
}
|
|
|
|
func (s *importTraversal) currentRoute(location Location) importRoute {
|
|
locations := make([]Location, 0, len(s.stack)+1)
|
|
for _, route := range s.stack {
|
|
if len(route.locations) != 0 {
|
|
locations = append(locations, route.locations[len(route.locations)-1])
|
|
}
|
|
}
|
|
locations = append(locations, location)
|
|
return importRoute{locations: locations}
|
|
}
|
|
|
|
func sourceInfos(source *SourceInfo) []SourceInfo {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return []SourceInfo{cloneSourceInfo(*source)}
|
|
}
|
|
|
|
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
|
|
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
|
|
}
|
|
|
|
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
|
|
paths := make([]string, 0, len(cycle)+1)
|
|
related := make([]Location, 0, len(cycle))
|
|
for _, route := range cycle {
|
|
paths = append(paths, route.canonical)
|
|
if len(route.locations) != 0 {
|
|
related = append(related, route.locations[len(route.locations)-1])
|
|
}
|
|
}
|
|
paths = append(paths, target)
|
|
return Diagnostic{
|
|
Code: CodeImportCycle, Severity: SeverityError,
|
|
Message: "import cycle detected: " + strings.Join(paths, " -> "),
|
|
Primary: &location, Related: related,
|
|
}
|
|
}
|
|
|
|
func importRepeatedDiagnostic(location Location, previous, current importRoute) Diagnostic {
|
|
related := append([]Location(nil), previous.locations...)
|
|
if len(current.locations) > 1 {
|
|
related = append(related, current.locations[:len(current.locations)-1]...)
|
|
}
|
|
return Diagnostic{
|
|
Code: CodeImportRepeated, Severity: SeverityError,
|
|
Message: "configuration source is included by more than one import route",
|
|
Primary: &location, Related: related,
|
|
}
|
|
}
|