382 lines
12 KiB
Go
382 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sync/atomic"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
const (
|
|
maxSourceBytes = 8 << 20
|
|
maxTotalBytes = 64 << 20
|
|
)
|
|
|
|
// Source is an opaque configuration input. Construct sources with File,
|
|
// Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid.
|
|
type Source struct {
|
|
kind sourceKind
|
|
path string
|
|
label string
|
|
namespace *nativeNamespace
|
|
data []byte
|
|
reader *readerSource
|
|
mapping *document.Node
|
|
mapIssue *valueIssue
|
|
mapBytes int64
|
|
mapNodes int
|
|
createErr sourceCreationError
|
|
}
|
|
|
|
type sourceKind uint8
|
|
|
|
const (
|
|
sourceInvalid sourceKind = iota
|
|
sourceFile
|
|
sourceFilesystem
|
|
sourceBytes
|
|
sourceReader
|
|
sourceMapping
|
|
sourceNone
|
|
)
|
|
|
|
type sourceCreationError uint8
|
|
|
|
const (
|
|
creationValid sourceCreationError = iota
|
|
creationInvalidPath
|
|
creationInvalidLabel
|
|
creationInvalidReader
|
|
creationTooLarge
|
|
creationInvalidFilesystem
|
|
creationInvalidFilesystemLabel
|
|
creationInvalidFilesystemPath
|
|
creationInvalidMappingRoot
|
|
)
|
|
|
|
// Filesystem is a reusable opaque namespace for caller-owned fs.FS inputs.
|
|
// Copies preserve the namespace identity used for explicit contexts.
|
|
type Filesystem struct {
|
|
namespace *nativeNamespace
|
|
createErr sourceCreationError
|
|
}
|
|
|
|
// NewFilesystem records a borrowed filesystem without opening it. Invalid
|
|
// arguments are reported when a File source is consumed.
|
|
func NewFilesystem(label string, filesystem fs.FS) Filesystem {
|
|
result := Filesystem{}
|
|
if !validOverrideLabel(label) {
|
|
result.createErr = creationInvalidFilesystemLabel
|
|
} else if nilFilesystem(filesystem) {
|
|
result.createErr = creationInvalidFilesystem
|
|
} else {
|
|
result.namespace = newNativeNamespace(label, filesystem)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// File selects a non-root slash-relative file within this namespace.
|
|
func (f Filesystem) File(name string) Source {
|
|
if f.createErr != creationValid {
|
|
return Source{kind: sourceFilesystem, createErr: f.createErr}
|
|
}
|
|
return filesystemFile(f.namespace, name)
|
|
}
|
|
|
|
func nilFilesystem(filesystem fs.FS) bool {
|
|
if filesystem == nil {
|
|
return true
|
|
}
|
|
value := reflect.ValueOf(filesystem)
|
|
switch value.Kind() {
|
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
|
return value.IsNil()
|
|
}
|
|
return false
|
|
}
|
|
|
|
type readerSource struct {
|
|
input io.Reader
|
|
used atomic.Bool
|
|
}
|
|
|
|
// File returns a source that reopens path for every load attempt.
|
|
func File(path string) Source {
|
|
if path == "" {
|
|
return Source{kind: sourceFile, createErr: creationInvalidPath}
|
|
}
|
|
return Source{kind: sourceFile, path: path, label: path}
|
|
}
|
|
|
|
func filesystemFile(namespace *nativeNamespace, name string) Source {
|
|
if namespace == nil || nilFilesystem(namespace.filesystem) {
|
|
return Source{kind: sourceFilesystem, createErr: creationInvalidFilesystem}
|
|
}
|
|
if name == "." || !fs.ValidPath(name) {
|
|
return Source{kind: sourceFilesystem, createErr: creationInvalidFilesystemPath}
|
|
}
|
|
return Source{kind: sourceFilesystem, path: name, label: namespace.label + "/" + name, namespace: namespace}
|
|
}
|
|
|
|
// Bytes snapshots data immediately. Label must be nonempty and safe to display;
|
|
// constructor problems are reported when the source is consumed.
|
|
func Bytes(label string, data []byte) Source {
|
|
source := Source{kind: sourceBytes, label: label}
|
|
if label == "" {
|
|
source.createErr = creationInvalidLabel
|
|
return source
|
|
}
|
|
if len(data) > maxSourceBytes {
|
|
source.createErr = creationTooLarge
|
|
return source
|
|
}
|
|
source.data = bytes.Clone(data)
|
|
return source
|
|
}
|
|
|
|
// Reader returns a single-use source. ConfigKit consumes input but never closes it.
|
|
// Copies of the returned Source share the same single-use claim.
|
|
func Reader(label string, input io.Reader) Source {
|
|
source := Source{kind: sourceReader, label: label}
|
|
if label == "" {
|
|
source.createErr = creationInvalidLabel
|
|
return source
|
|
}
|
|
if nilReader(input) {
|
|
source.createErr = creationInvalidReader
|
|
return source
|
|
}
|
|
source.reader = &readerSource{input: input}
|
|
return source
|
|
}
|
|
|
|
// Mapping snapshots supported built-in values into an owned document at
|
|
// construction. Invalid values are retained as safe deferred diagnostics.
|
|
func Mapping(label string, values map[string]any) Source {
|
|
source := Source{kind: sourceMapping, label: label}
|
|
if !validOverrideLabel(label) {
|
|
source.createErr = creationInvalidLabel
|
|
return source
|
|
}
|
|
if values == nil {
|
|
source.createErr = creationInvalidMappingRoot
|
|
return source
|
|
}
|
|
builder := valueBuilder{active: make(map[valueVisit]bool)}
|
|
source.mapping, source.mapIssue = builder.build(values, Path{}, 0)
|
|
source.mapBytes, source.mapNodes = builder.bytes, builder.nodes
|
|
return source
|
|
}
|
|
|
|
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
|
func NoSource() Source {
|
|
return Source{kind: sourceNone}
|
|
}
|
|
|
|
type sourceLimits struct {
|
|
perSource int64
|
|
remaining int64
|
|
}
|
|
|
|
func defaultSourceLimits() sourceLimits {
|
|
return sourceLimits{perSource: maxSourceBytes, remaining: maxTotalBytes}
|
|
}
|
|
|
|
type sourceRead struct {
|
|
data []byte
|
|
document *document.Node
|
|
bytes int64
|
|
info *SourceInfo
|
|
}
|
|
|
|
func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead, []Diagnostic) {
|
|
if diagnostic := sourceConstructorDiagnostic(source); diagnostic != nil {
|
|
if source.kind == sourceMapping {
|
|
diagnostic.Primary = &Location{SourceID: sourceID}
|
|
return sourceRead{info: mappingSourceInfo(source, sourceID)}, []Diagnostic{*diagnostic}
|
|
}
|
|
return sourceRead{}, []Diagnostic{*diagnostic}
|
|
}
|
|
if source.kind == sourceMapping && source.mapIssue != nil {
|
|
diagnostic := source.mapIssue.sourceDiagnostic(sourceID)
|
|
return sourceRead{info: mappingSourceInfo(source, sourceID)}, []Diagnostic{diagnostic}
|
|
}
|
|
if source.kind == sourceNone {
|
|
return sourceRead{}, nil
|
|
}
|
|
|
|
limit := min(limits.perSource, limits.remaining)
|
|
if limit < 0 {
|
|
limit = 0
|
|
}
|
|
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase, Kind: sourceSourceKind(source.kind)}
|
|
|
|
var input io.Reader
|
|
switch source.kind {
|
|
case sourceFile:
|
|
absolute, err := filepath.Abs(source.path)
|
|
if err != nil {
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceRead, sourceID, "source path could not be resolved")}
|
|
}
|
|
info.Path = filepath.Clean(absolute)
|
|
canonical, err := filepath.EvalSymlinks(info.Path)
|
|
if err != nil {
|
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
}
|
|
info.Path = canonical
|
|
data, failure := readRegularFile(canonical, os.Stat, os.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 sourceFilesystem:
|
|
access := newNativeAccess(source.namespace.filesystem, ".", source.namespace.token)
|
|
info.Path = source.path
|
|
info.namespace = source.namespace.token
|
|
canonical, err := access.canonical(source.path)
|
|
if err != nil {
|
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
}
|
|
info.Path = canonical
|
|
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:
|
|
input = bytes.NewReader(source.data)
|
|
case sourceReader:
|
|
if !source.reader.used.CompareAndSwap(false, true) {
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceReaderUsed, sourceID, "reader source has already been consumed")}
|
|
}
|
|
input = source.reader.input
|
|
case sourceMapping:
|
|
if source.mapBytes > limit || source.mapNodes > maxDocumentNodes {
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "mapping source exceeds the configured input limit")}
|
|
}
|
|
owned := document.Clone(source.mapping)
|
|
setDocumentSourceID(owned, sourceID)
|
|
return sourceRead{document: owned, bytes: source.mapBytes, info: info}, nil
|
|
default:
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
|
}
|
|
data, failure := readBoundedSource(input, 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
|
|
}
|
|
|
|
func mappingSourceInfo(source Source, sourceID string) *SourceInfo {
|
|
label := source.label
|
|
if !validOverrideLabel(label) {
|
|
label = "mapping"
|
|
}
|
|
return &SourceInfo{ID: sourceID, Label: label, Layer: LayerBase, Kind: SourceKindMapping}
|
|
}
|
|
|
|
func sourceConstructorDiagnostic(source Source) *Diagnostic {
|
|
var message string
|
|
code := CodeSourceInvalid
|
|
switch source.createErr {
|
|
case creationValid:
|
|
if source.kind != sourceInvalid {
|
|
return nil
|
|
}
|
|
message = "source is invalid"
|
|
case creationInvalidPath:
|
|
message = "source path must not be empty"
|
|
case creationInvalidLabel:
|
|
message = "source label must not be empty"
|
|
if source.kind == sourceMapping {
|
|
message = "mapping source label must be nonempty and safe to display"
|
|
}
|
|
case creationInvalidReader:
|
|
message = "source reader must not be nil"
|
|
case creationTooLarge:
|
|
code = CodeSourceLimitExceeded
|
|
message = "source exceeds the configured byte limit"
|
|
case creationInvalidFilesystem:
|
|
message = "filesystem must not be nil"
|
|
case creationInvalidFilesystemLabel:
|
|
message = "filesystem label must be nonempty and safe to display"
|
|
case creationInvalidFilesystemPath:
|
|
message = "filesystem source path must be a non-root slash-relative valid path"
|
|
case creationInvalidMappingRoot:
|
|
message = "mapping source root must be a non-nil mapping"
|
|
}
|
|
diagnostic := sourceDiagnostic(code, "", message)
|
|
return &diagnostic
|
|
}
|
|
|
|
func sourceSourceKind(kind sourceKind) SourceKind {
|
|
switch kind {
|
|
case sourceFile:
|
|
return SourceKindFile
|
|
case sourceFilesystem:
|
|
return SourceKindFilesystem
|
|
case sourceBytes:
|
|
return SourceKindBytes
|
|
case sourceReader:
|
|
return SourceKindReader
|
|
case sourceMapping:
|
|
return SourceKindMapping
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func setDocumentSourceID(node *document.Node, sourceID string) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
node.Location.SourceID = sourceID
|
|
for index := range node.Mapping {
|
|
entry := &node.Mapping[index]
|
|
entry.KeyLocation.SourceID = sourceID
|
|
entry.ValueLocation.SourceID = sourceID
|
|
setDocumentSourceID(entry.Value, sourceID)
|
|
}
|
|
for _, child := range node.Sequence {
|
|
setDocumentSourceID(child, sourceID)
|
|
}
|
|
}
|
|
|
|
func classifySourceError(err error, sourceID string) Diagnostic {
|
|
switch {
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
return sourceDiagnostic(CodeSourceMissing, sourceID, "source does not exist")
|
|
case errors.Is(err, fs.ErrPermission):
|
|
return sourceDiagnostic(CodeSourcePermission, sourceID, "source cannot be accessed")
|
|
default:
|
|
return sourceDiagnostic(CodeSourceRead, sourceID, "source could not be read")
|
|
}
|
|
}
|
|
|
|
func sourceDiagnostic(code Code, sourceID, message string) Diagnostic {
|
|
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Message: message}
|
|
if sourceID != "" {
|
|
diagnostic.Primary = &Location{SourceID: sourceID}
|
|
}
|
|
return diagnostic
|
|
}
|
|
|
|
func nilReader(input io.Reader) bool {
|
|
if input == nil {
|
|
return true
|
|
}
|
|
value := reflect.ValueOf(input)
|
|
switch value.Kind() {
|
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
|
return value.IsNil()
|
|
default:
|
|
return false
|
|
}
|
|
}
|