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) canonical, err := filepath.EvalSymlinks(info.Path) if err != nil { return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)} } info.Path = canonical preflight, err := os.Stat(canonical) if err != nil { return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)} } if !preflight.Mode().IsRegular() { return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")} } file, err := os.Open(canonical) 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")} } 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 } }