Files
ConfigKit/internal/engine/source.go

389 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
search *Search
searchApp string
searchRequired bool
searchIssues []string
}
type sourceKind uint8
const (
sourceInvalid sourceKind = iota
sourceFile
sourceFilesystem
sourceBytes
sourceReader
sourceMapping
sourceNone
sourceSearch
)
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 selects an absent input for defaults and resolution. It creates no
// source record and has no main declarations to traverse when imports are enabled.
// An active overlay requires explicit filesystem context.
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
}
}