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 ".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 }