io.go 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. package config
  2. import (
  3. "bytes"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "io/fs"
  8. "os"
  9. "path/filepath"
  10. "gopkg.in/yaml.v3"
  11. )
  12. const (
  13. dirMode fs.FileMode = 0o700
  14. fileMode fs.FileMode = 0o600
  15. )
  16. // LoadResult bundles a loaded config with a non-fatal warning (if any).
  17. // A missing file returns an empty Config and no warning.
  18. type LoadResult struct {
  19. Config Config
  20. Warning string // e.g. permission > 0600
  21. }
  22. // Load reads and parses the config at path. If the file does not exist, an
  23. // empty Config is returned without error (first-run behavior). Files with
  24. // permissions more permissive than 0600 still load, but the result carries a
  25. // Warning the caller should surface.
  26. func Load(path string) (LoadResult, error) {
  27. var res LoadResult
  28. info, err := os.Stat(path)
  29. if err != nil {
  30. if errors.Is(err, fs.ErrNotExist) {
  31. return res, nil
  32. }
  33. return res, fmt.Errorf("stat config %q: %w", path, err)
  34. }
  35. if !info.Mode().IsRegular() {
  36. return res, fmt.Errorf("config path %q is not a regular file", path)
  37. }
  38. if info.Mode().Perm()&0o077 != 0 {
  39. res.Warning = fmt.Sprintf(
  40. "warning: config %s has permissions %#o; API keys may be readable by other users. Consider `chmod 600 %s`.",
  41. path, info.Mode().Perm(), path,
  42. )
  43. }
  44. data, err := os.ReadFile(path)
  45. if err != nil {
  46. return res, fmt.Errorf("read config %q: %w", path, err)
  47. }
  48. if len(data) == 0 {
  49. return res, nil
  50. }
  51. if err := yaml.Unmarshal(data, &res.Config); err != nil {
  52. return res, fmt.Errorf("parse config %q: %w", path, err)
  53. }
  54. if err := res.Config.Validate(); err != nil {
  55. return res, fmt.Errorf("invalid config %q: %w", path, err)
  56. }
  57. return res, nil
  58. }
  59. // Save serialises cfg and writes it to path atomically:
  60. //
  61. // 1. ensure parent dir exists (0700)
  62. // 2. write to sibling "<path>.tmp" with 0600 perms
  63. // 3. fsync
  64. // 4. os.Rename -> path
  65. //
  66. // Callers should Validate() before invoking Save() if they've built a Config
  67. // programmatically (Add/Update/Remove methods already enforce invariants).
  68. func Save(path string, cfg Config) error {
  69. if err := cfg.Validate(); err != nil {
  70. return err
  71. }
  72. dir := filepath.Dir(path)
  73. if err := os.MkdirAll(dir, dirMode); err != nil {
  74. return fmt.Errorf("create config dir %q: %w", dir, err)
  75. }
  76. data, err := yaml.Marshal(cfg)
  77. if err != nil {
  78. return fmt.Errorf("marshal config: %w", err)
  79. }
  80. tmp, err := os.CreateTemp(dir, ".config.yaml.*.tmp")
  81. if err != nil {
  82. return fmt.Errorf("create temp file in %q: %w", dir, err)
  83. }
  84. tmpPath := tmp.Name()
  85. // Clean up on any failure path.
  86. committed := false
  87. defer func() {
  88. if !committed {
  89. _ = os.Remove(tmpPath)
  90. }
  91. }()
  92. if err := tmp.Chmod(fileMode); err != nil {
  93. _ = tmp.Close()
  94. return fmt.Errorf("chmod temp file: %w", err)
  95. }
  96. if _, err := io.Copy(tmp, bytes.NewReader(data)); err != nil {
  97. _ = tmp.Close()
  98. return fmt.Errorf("write temp file: %w", err)
  99. }
  100. if err := tmp.Sync(); err != nil {
  101. _ = tmp.Close()
  102. return fmt.Errorf("fsync temp file: %w", err)
  103. }
  104. if err := tmp.Close(); err != nil {
  105. return fmt.Errorf("close temp file: %w", err)
  106. }
  107. if err := os.Rename(tmpPath, path); err != nil {
  108. return fmt.Errorf("rename temp into place: %w", err)
  109. }
  110. committed = true
  111. return nil
  112. }