355 lines
8.9 KiB
Go
355 lines
8.9 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/toml"
|
|
)
|
|
|
|
type fileSettings struct{ security SecurityOptions }
|
|
type fileSnapshot struct {
|
|
data []byte
|
|
info os.FileInfo
|
|
digest [32]byte
|
|
}
|
|
|
|
func (c *Config) fileSettingsLocked() fileSettings {
|
|
var s fileSettings
|
|
if c.securityOpts != nil {
|
|
s.security = *c.securityOpts
|
|
}
|
|
return s
|
|
}
|
|
|
|
type contextReader struct {
|
|
ctx context.Context
|
|
reader io.Reader
|
|
}
|
|
|
|
func (r contextReader) Read(p []byte) (int, error) {
|
|
if err := r.ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
return r.reader.Read(p)
|
|
}
|
|
|
|
// Read a regular file and validate the opened descriptor, not just the pathname.
|
|
func readConfigFile(ctx context.Context, path string, opts fileSettings) (*fileSnapshot, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if opts.security.PreventPathTraversal {
|
|
clean := filepath.Clean(path)
|
|
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
return nil, wrapError(ErrFileAccess, fmt.Errorf("path traversal in %q", path))
|
|
}
|
|
}
|
|
if ext := strings.ToLower(filepath.Ext(path)); ext == ".json" || ext == ".yaml" || ext == ".yml" {
|
|
return nil, ErrFileFormat
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
return nil, wrapError(ErrFileAccess, err)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file"))
|
|
}
|
|
file, err := openConfig(path)
|
|
if err != nil {
|
|
return nil, wrapError(ErrFileAccess, err)
|
|
}
|
|
defer file.Close()
|
|
info, err = file.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file"))
|
|
}
|
|
if opts.security.EnforceFileOwnership {
|
|
if err := checkFileOwner(info); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
limit := opts.security.MaxFileSize
|
|
if limit > 0 && info.Size() > limit {
|
|
return nil, wrapError(ErrFileAccess, fmt.Errorf("config exceeds maximum size %d", limit))
|
|
}
|
|
var reader io.Reader = contextReader{ctx, file}
|
|
if limit > 0 && limit < int64(^uint64(0)>>1) {
|
|
reader = io.LimitReader(reader, limit+1)
|
|
}
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if limit > 0 && int64(len(data)) > limit {
|
|
return nil, wrapError(ErrFileAccess, fmt.Errorf("config exceeds maximum size %d", limit))
|
|
}
|
|
after, err := file.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.Size() != after.Size() || !info.ModTime().Equal(after.ModTime()) {
|
|
return nil, fmt.Errorf("config changed during read")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &fileSnapshot{data, after, sha256.Sum256(data)}, nil
|
|
}
|
|
|
|
// openConfig opens without blocking: a FIFO swapped in after the Stat cannot
|
|
// hang open(2), and a regular-looking file whose reads block (/proc/kmsg)
|
|
// fails its read instead
|
|
func openConfig(path string) (*os.File, error) {
|
|
return os.OpenFile(path, os.O_RDONLY|openNonBlock, 0)
|
|
}
|
|
|
|
func parseFile(snapshot *fileSnapshot) (map[string]any, []string, error) {
|
|
root, err := toml.NewParser(snapshot.data).Parse()
|
|
if err != nil {
|
|
return nil, nil, wrapError(ErrDecode, fmt.Errorf("failed to parse TOML: %w", err))
|
|
}
|
|
var comments []string
|
|
lexer := toml.NewLexer(snapshot.data)
|
|
for {
|
|
token := lexer.NextToken()
|
|
if token.Type == toml.TokenEOF {
|
|
break
|
|
}
|
|
if token.Type == toml.TokenError {
|
|
return nil, nil, wrapError(ErrDecode, fmt.Errorf("%s", token.Literal))
|
|
}
|
|
if token.Type == toml.TokenComment {
|
|
comments = append(comments, token.Literal)
|
|
}
|
|
}
|
|
return root, comments, nil
|
|
}
|
|
|
|
func (c *Config) loadFile(path string) error {
|
|
c.mutex.RLock()
|
|
opts := c.fileSettingsLocked()
|
|
c.mutex.RUnlock()
|
|
snapshot, err := readConfigFile(context.Background(), path, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root, comments, err := parseFile(snapshot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
if err := c.applyFileLocked(root, comments, path); err != nil {
|
|
return err
|
|
}
|
|
if c.watcher != nil && c.watcher.filePath != path {
|
|
c.watcher.stop()
|
|
c.watcher = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) applyFileLocked(root map[string]any, comments []string, path string, guard ...func() error) error {
|
|
values := make(map[string]any)
|
|
for registered := range c.items {
|
|
var current any = root
|
|
exists := true
|
|
for _, key := range strings.Split(registered, ".") {
|
|
m, ok := current.(map[string]any)
|
|
if !ok {
|
|
return wrapError(ErrTypeMismatch, fmt.Errorf("scalar parent of registered path %q", registered))
|
|
}
|
|
current, exists = m[key]
|
|
if !exists {
|
|
break
|
|
}
|
|
}
|
|
if exists {
|
|
values[registered] = current
|
|
}
|
|
}
|
|
if err := c.replaceSourceLocked(SourceFile, values, guard...); err != nil {
|
|
return err
|
|
}
|
|
c.configFilePath, c.fileComments = path, comments
|
|
c.fileGeneration++
|
|
return nil
|
|
}
|
|
|
|
// Save writes registered effective values as TOML. Comments from the loaded file
|
|
// are retained as a preamble; their original positions and whitespace are not.
|
|
func (c *Config) Save(path string) error { return c.save(path, "") }
|
|
func (c *Config) SaveSource(path string, source Source) error {
|
|
if !validSource(source) {
|
|
return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source))
|
|
}
|
|
return c.save(path, source)
|
|
}
|
|
func (c *Config) save(path string, source Source) error {
|
|
c.mutex.RLock()
|
|
nested := c.nestedLocked(source)
|
|
comments := append([]string(nil), c.fileComments...)
|
|
c.mutex.RUnlock()
|
|
data, err := marshalConfig(nested)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var preamble strings.Builder
|
|
for _, comment := range comments {
|
|
preamble.WriteByte('#')
|
|
preamble.WriteString(comment)
|
|
preamble.WriteByte('\n')
|
|
}
|
|
return atomicWriteFile(path, append([]byte(preamble.String()), data...))
|
|
}
|
|
|
|
func marshalConfig(value any) ([]byte, error) {
|
|
normalized, err := fileValue(reflect.ValueOf(value), 0)
|
|
if err != nil {
|
|
return nil, wrapError(ErrFileFormat, err)
|
|
}
|
|
data, err := toml.Marshal(normalized)
|
|
if err != nil {
|
|
return nil, wrapError(ErrFileFormat, err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func fileValue(v reflect.Value, depth int) (any, error) {
|
|
if depth > maxValueDepth {
|
|
return nil, fmt.Errorf("file value nesting exceeds %d", maxValueDepth)
|
|
}
|
|
for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) {
|
|
if v.IsNil() {
|
|
return nil, nil
|
|
}
|
|
v = v.Elem()
|
|
depth++
|
|
if depth > maxValueDepth {
|
|
return nil, fmt.Errorf("cyclic pointer")
|
|
}
|
|
}
|
|
if !v.IsValid() {
|
|
return nil, nil
|
|
}
|
|
switch v.Type() {
|
|
case durationType:
|
|
return time.Duration(v.Int()).String(), nil
|
|
case timeType:
|
|
return v.Interface().(time.Time).Format(time.RFC3339Nano), nil
|
|
case ipType:
|
|
ip := v.Interface().(net.IP)
|
|
if len(ip) == 0 {
|
|
return nil, nil
|
|
}
|
|
return ip.String(), nil
|
|
case ipNetType:
|
|
network := v.Interface().(net.IPNet)
|
|
return network.String(), nil
|
|
case urlType:
|
|
u := v.Interface().(url.URL)
|
|
return u.String(), nil
|
|
}
|
|
switch v.Kind() {
|
|
case reflect.Struct:
|
|
out := make(map[string]any)
|
|
for i := 0; i < v.NumField(); i++ {
|
|
f := v.Type().Field(i)
|
|
if !f.IsExported() || fieldKey(f) == "-" {
|
|
continue
|
|
}
|
|
value, err := fileValue(v.Field(i), depth+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key := fieldKey(f)
|
|
if _, exists := out[key]; exists {
|
|
return nil, fmt.Errorf("duplicate TOML field %q", key)
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out, nil
|
|
case reflect.Map:
|
|
if v.Type().Key().Kind() != reflect.String {
|
|
return nil, fmt.Errorf("map keys must be strings")
|
|
}
|
|
out := make(map[string]any, v.Len())
|
|
it := v.MapRange()
|
|
for it.Next() {
|
|
value, err := fileValue(it.Value(), depth+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[it.Key().String()] = value
|
|
}
|
|
return out, nil
|
|
case reflect.Array, reflect.Slice:
|
|
out := make([]any, v.Len())
|
|
for i := range out {
|
|
value, err := fileValue(v.Index(i), depth+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = value
|
|
}
|
|
return out, nil
|
|
default:
|
|
return v.Interface(), nil
|
|
}
|
|
}
|
|
|
|
func atomicWriteFile(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, DirPermissions); err != nil {
|
|
return wrapError(ErrFileAccess, err)
|
|
}
|
|
mode := os.FileMode(0600)
|
|
if info, err := os.Stat(path); err == nil {
|
|
if !info.Mode().IsRegular() {
|
|
return wrapError(ErrFileAccess, fmt.Errorf("destination must be a regular file"))
|
|
}
|
|
mode = info.Mode().Perm()
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return wrapError(ErrFileAccess, err)
|
|
}
|
|
f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
|
|
if err != nil {
|
|
return wrapError(ErrFileAccess, err)
|
|
}
|
|
defer os.Remove(f.Name())
|
|
defer f.Close()
|
|
if _, err = f.Write(data); err == nil {
|
|
err = f.Chmod(mode)
|
|
}
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
if closeErr := f.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err == nil {
|
|
err = os.Rename(f.Name(), path)
|
|
}
|
|
if err != nil {
|
|
return wrapError(ErrFileAccess, err)
|
|
}
|
|
return nil
|
|
}
|