| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125 |
- package config
- import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "os"
- "path/filepath"
- "gopkg.in/yaml.v3"
- )
- const (
- dirMode fs.FileMode = 0o700
- fileMode fs.FileMode = 0o600
- )
- // LoadResult bundles a loaded config with a non-fatal warning (if any).
- // A missing file returns an empty Config and no warning.
- type LoadResult struct {
- Config Config
- Warning string // e.g. permission > 0600
- }
- // Load reads and parses the config at path. If the file does not exist, an
- // empty Config is returned without error (first-run behavior). Files with
- // permissions more permissive than 0600 still load, but the result carries a
- // Warning the caller should surface.
- func Load(path string) (LoadResult, error) {
- var res LoadResult
- info, err := os.Stat(path)
- if err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return res, nil
- }
- return res, fmt.Errorf("stat config %q: %w", path, err)
- }
- if !info.Mode().IsRegular() {
- return res, fmt.Errorf("config path %q is not a regular file", path)
- }
- if info.Mode().Perm()&0o077 != 0 {
- res.Warning = fmt.Sprintf(
- "warning: config %s has permissions %#o; API keys may be readable by other users. Consider `chmod 600 %s`.",
- path, info.Mode().Perm(), path,
- )
- }
- data, err := os.ReadFile(path)
- if err != nil {
- return res, fmt.Errorf("read config %q: %w", path, err)
- }
- if len(data) == 0 {
- return res, nil
- }
- if err := yaml.Unmarshal(data, &res.Config); err != nil {
- return res, fmt.Errorf("parse config %q: %w", path, err)
- }
- if err := res.Config.Validate(); err != nil {
- return res, fmt.Errorf("invalid config %q: %w", path, err)
- }
- return res, nil
- }
- // Save serialises cfg and writes it to path atomically:
- //
- // 1. ensure parent dir exists (0700)
- // 2. write to sibling "<path>.tmp" with 0600 perms
- // 3. fsync
- // 4. os.Rename -> path
- //
- // Callers should Validate() before invoking Save() if they've built a Config
- // programmatically (Add/Update/Remove methods already enforce invariants).
- func Save(path string, cfg Config) error {
- if err := cfg.Validate(); err != nil {
- return err
- }
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, dirMode); err != nil {
- return fmt.Errorf("create config dir %q: %w", dir, err)
- }
- data, err := yaml.Marshal(cfg)
- if err != nil {
- return fmt.Errorf("marshal config: %w", err)
- }
- tmp, err := os.CreateTemp(dir, ".config.yaml.*.tmp")
- if err != nil {
- return fmt.Errorf("create temp file in %q: %w", dir, err)
- }
- tmpPath := tmp.Name()
- // Clean up on any failure path.
- committed := false
- defer func() {
- if !committed {
- _ = os.Remove(tmpPath)
- }
- }()
- if err := tmp.Chmod(fileMode); err != nil {
- _ = tmp.Close()
- return fmt.Errorf("chmod temp file: %w", err)
- }
- if _, err := io.Copy(tmp, bytes.NewReader(data)); err != nil {
- _ = tmp.Close()
- return fmt.Errorf("write temp file: %w", err)
- }
- if err := tmp.Sync(); err != nil {
- _ = tmp.Close()
- return fmt.Errorf("fsync temp file: %w", err)
- }
- if err := tmp.Close(); err != nil {
- return fmt.Errorf("close temp file: %w", err)
- }
- if err := os.Rename(tmpPath, path); err != nil {
- return fmt.Errorf("rename temp into place: %w", err)
- }
- committed = true
- return nil
- }
|