// 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_<UPPER> 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_<UPPER> 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
}