Files
config/file.go
T

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
}