242 lines
6.4 KiB
Go
242 lines
6.4 KiB
Go
package configkit
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sync/atomic"
|
|
)
|
|
|
|
const (
|
|
maxSourceBytes = 8 << 20
|
|
maxTotalBytes = 64 << 20
|
|
)
|
|
|
|
// Source is an opaque configuration input. Construct sources with File, Bytes,
|
|
// Reader, or NoSource; the zero value is invalid.
|
|
type Source struct {
|
|
kind sourceKind
|
|
path string
|
|
label string
|
|
data []byte
|
|
reader *readerSource
|
|
createErr sourceCreationError
|
|
}
|
|
|
|
type sourceKind uint8
|
|
|
|
const (
|
|
sourceInvalid sourceKind = iota
|
|
sourceFile
|
|
sourceBytes
|
|
sourceReader
|
|
sourceNone
|
|
)
|
|
|
|
type sourceCreationError uint8
|
|
|
|
const (
|
|
creationValid sourceCreationError = iota
|
|
creationInvalidPath
|
|
creationInvalidLabel
|
|
creationInvalidReader
|
|
creationTooLarge
|
|
)
|
|
|
|
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}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
info *SourceInfo
|
|
}
|
|
|
|
func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead, []Diagnostic) {
|
|
if diagnostic := sourceConstructorDiagnostic(source); diagnostic != nil {
|
|
return sourceRead{}, []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}
|
|
|
|
var (
|
|
input io.Reader
|
|
close func() error
|
|
)
|
|
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)
|
|
file, err := os.Open(source.path)
|
|
if err != nil {
|
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
}
|
|
close = file.Close
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
}
|
|
if !stat.Mode().IsRegular() {
|
|
_ = file.Close()
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")}
|
|
}
|
|
canonical, err := filepath.EvalSymlinks(info.Path)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
}
|
|
info.Path = canonical
|
|
if stat.Size() > limit {
|
|
_ = file.Close()
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
|
}
|
|
input = file
|
|
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
|
|
default:
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
|
}
|
|
if close != nil {
|
|
defer func() { _ = close() }()
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(input, limit+1))
|
|
if err != nil {
|
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
}
|
|
if int64(len(data)) > limit {
|
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
|
}
|
|
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
|
}
|
|
|
|
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"
|
|
case creationInvalidReader:
|
|
message = "source reader must not be nil"
|
|
case creationTooLarge:
|
|
code = CodeSourceLimitExceeded
|
|
message = "source exceeds the configured byte limit"
|
|
}
|
|
diagnostic := sourceDiagnostic(code, "", message)
|
|
return &diagnostic
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|