// Package config loads cacher configuration with precedence: // flag > env (CACHER_*) > config file (~/.config/cacher/config.toml) > defaults. package config import ( "errors" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/BurntSushi/toml" ) // Config is the persisted cacher configuration. The TOML file under // ~/.config/cacher/config.toml holds the same fields. Every field is // overridable via CACHER_ environment variables. type Config struct { Endpoint string `toml:"endpoint"` Region string `toml:"region"` Bucket string `toml:"bucket"` Prefix string `toml:"prefix"` ArchSuffix bool `toml:"arch_suffix"` // KeyFile / SecretFile point at files that contain the raw credential // material (whitespace is stripped on load). Used in CI where secrets // are mounted as files. Env vars CACHER_S3_KEY_ID / CACHER_S3_SECRET // take priority for local-dev runs. KeyFile string `toml:"key_file"` SecretFile string `toml:"secret_file"` } // Defaults returns built-in defaults. They are overlaid by file → env → flags. func Defaults() Config { return Config{ Region: "us-east-1", KeyFile: "~/.s3-cache-key-id", SecretFile: "~/.s3-cache-key-secret", } } // DefaultPath returns ~/.config/cacher/config.toml expanded. func DefaultPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("user config dir: %w", err) } return filepath.Join(dir, "cacher", "config.toml"), nil } // Load reads the config file at path (empty path = DefaultPath). A missing // file is not an error — it returns Defaults(). Env vars and flag overlays // must be applied by the caller afterwards. func Load(path string) (Config, error) { cfg := Defaults() if path == "" { p, err := DefaultPath() if err != nil { return cfg, err } path = p } data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return cfg, nil } if err != nil { return cfg, fmt.Errorf("read %s: %w", path, err) } if err := toml.Unmarshal(data, &cfg); err != nil { return cfg, fmt.Errorf("parse %s: %w", path, err) } return cfg, nil } // Save writes the config to path (creating parent dirs). Empty path = // DefaultPath. func Save(path string, cfg Config) error { if path == "" { p, err := DefaultPath() if err != nil { return err } path = p } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("open %s: %w", path, err) } defer f.Close() return toml.NewEncoder(f).Encode(cfg) } // ApplyEnv overlays CACHER_ environment variables onto cfg. // Unset/empty variables are ignored (do not zero out file values). func ApplyEnv(cfg *Config) error { if v, ok := os.LookupEnv("CACHER_ENDPOINT"); ok && v != "" { cfg.Endpoint = v } if v, ok := os.LookupEnv("CACHER_REGION"); ok && v != "" { cfg.Region = v } if v, ok := os.LookupEnv("CACHER_BUCKET"); ok && v != "" { cfg.Bucket = v } if v, ok := os.LookupEnv("CACHER_PREFIX"); ok && v != "" { cfg.Prefix = v } if v, ok := os.LookupEnv("CACHER_ARCH_SUFFIX"); ok && v != "" { b, err := strconv.ParseBool(v) if err != nil { return fmt.Errorf("CACHER_ARCH_SUFFIX=%q: %w", v, err) } cfg.ArchSuffix = b } if v, ok := os.LookupEnv("CACHER_KEY_FILE"); ok && v != "" { cfg.KeyFile = v } if v, ok := os.LookupEnv("CACHER_SECRET_FILE"); ok && v != "" { cfg.SecretFile = v } return nil } // ExpandPath expands a leading ~ in a path against $HOME. func ExpandPath(p string) string { if p == "" || !strings.HasPrefix(p, "~") { return p } home, err := os.UserHomeDir() if err != nil { return p } if p == "~" { return home } if strings.HasPrefix(p, "~/") { return filepath.Join(home, p[2:]) } return p } // Credentials resolves the S3 access key + secret with precedence: // 1. CACHER_S3_KEY_ID / CACHER_S3_SECRET env vars // 2. files at cfg.KeyFile / cfg.SecretFile // // Whitespace (including trailing newlines) is trimmed from file contents. func (c Config) Credentials() (keyID, secret string, err error) { keyID = os.Getenv("CACHER_S3_KEY_ID") secret = os.Getenv("CACHER_S3_SECRET") if keyID == "" { v, e := readTrimmed(ExpandPath(c.KeyFile)) if e != nil { return "", "", fmt.Errorf("read key file %s: %w", c.KeyFile, e) } keyID = v } if secret == "" { v, e := readTrimmed(ExpandPath(c.SecretFile)) if e != nil { return "", "", fmt.Errorf("read secret file %s: %w", c.SecretFile, e) } secret = v } if keyID == "" || secret == "" { return "", "", errors.New("no credentials: set CACHER_S3_KEY_ID/_SECRET or --key-file/--secret-file") } return keyID, secret, nil } func readTrimmed(p string) (string, error) { if p == "" { return "", nil } b, err := os.ReadFile(p) if errors.Is(err, os.ErrNotExist) { return "", nil } if err != nil { return "", err } return strings.TrimSpace(string(b)), nil } // Validate ensures the minimum viable config for an S3 operation. func (c Config) Validate() error { var missing []string if c.Endpoint == "" { missing = append(missing, "endpoint") } if c.Region == "" { missing = append(missing, "region") } if c.Bucket == "" { missing = append(missing, "bucket") } if len(missing) > 0 { return fmt.Errorf("config missing fields: %s (run `cacher init` or set CACHER_* env vars)", strings.Join(missing, ", ")) } return nil }