From a3c83bd464294009fac5caf3874f957a9897a6c4 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 06:26:22 -0500 Subject: [PATCH] Add config loading from MRED_* env vars and 0600 env files --- internal/config/config.go | 191 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 107 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..09f6c8a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,191 @@ +// Package config loads Redmine connection settings for the mred CLI. +// The base URL and API key arrive ONLY from (a) MRED_URL / MRED_KEY +// environment variables or (b) a 0600 env file parsed in pure Go — never +// from flags or command-line arguments. Files looser than 0600 are +// refused BEFORE being read. Error messages carry line numbers and key +// names, never values; the API key is never logged or echoed. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Keys understood in the environment and the env file. +const ( + KeyURL = "MRED_URL" + KeyKey = "MRED_KEY" +) + +// Config is the resolved connection set. +type Config struct { + BaseURL string + APIKey string +} + +// Source records where each value came from (for safe diagnostics). +type Source struct{ Env, File string } + +// Load resolves settings: process env wins over the env file. path may +// be empty (file simply not consulted). A file that exists but is looser +// than 0600 is an error before any read. Both MRED_URL and MRED_KEY are +// required; there is no default server. +func Load(path string) (*Config, *Source, error) { + cfg := &Config{} + src := &Source{} + + fileVals, err := loadFile(path) + if err != nil { + return nil, nil, err + } + + get := func(key string) (string, bool) { + if v, ok := os.LookupEnv(key); ok && v != "" { + src.Env = key + return v, true + } + if v, ok := fileVals[key]; ok && v != "" { + src.File = key + return v, true + } + return "", false + } + + if v, ok := get(KeyURL); ok { + cfg.BaseURL = strings.TrimRight(v, "/") + } + if v, ok := get(KeyKey); ok { + cfg.APIKey = v + } + + var missing []string + if cfg.BaseURL == "" { + missing = append(missing, KeyURL) + } + if cfg.APIKey == "" { + missing = append(missing, KeyKey) + } + if len(missing) > 0 { + return nil, nil, fmt.Errorf("config: missing %s (set env or %s)", strings.Join(missing, " and "), pathOrDefault(path)) + } + return cfg, src, nil +} + +// loadFile reads and parses path when given. It enforces the 0600 rule +// before reading a single byte; a missing file is not an error (env may +// carry everything). +func loadFile(path string) (map[string]string, error) { + if path == "" { + return nil, nil + } + info, err := os.Stat(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("config: %s: %w", filepath.Base(path), err) + } + if info.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("config: %s: insecure mode %04o (must be 0600 or stricter)", filepath.Base(path), info.Mode().Perm()) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("config: %s: unreadable", filepath.Base(path)) + } + vals, err := parseEnvFile(data) + if err != nil { + return nil, fmt.Errorf("config: %s: %w", filepath.Base(path), err) + } + return vals, nil +} + +// parseEnvFile parses KEY=VALUE lines in pure Go (same discipline as +// mopac-bitwarden-go): no sourcing, no shell expansion, no interpolation. +// Comments, blank lines, an optional "export " prefix and one matched pair +// of surrounding quotes are handled; later duplicate keys win. Malformed +// lines fail with the line NUMBER only — never the contents. +func parseEnvFile(data []byte) (map[string]string, error) { + out := map[string]string{} + for i, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, "\r") + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + line = trimmed + if strings.HasPrefix(line, "export ") || strings.HasPrefix(line, "export\t") { + line = strings.TrimSpace(line[len("export"):]) + } + eq := strings.IndexByte(line, '=') + if eq <= 0 { + return nil, fmt.Errorf("line %d: malformed KEY=VALUE line", i+1) + } + key := strings.TrimSpace(line[:eq]) + if !validEnvKey(key) { + return nil, fmt.Errorf("line %d: malformed KEY=VALUE line", i+1) + } + value := strings.TrimSpace(line[eq+1:]) + if idx := commentIndex(value); idx >= 0 { + value = strings.TrimSpace(value[:idx]) + } + out[key] = unquote(value) + } + return out, nil +} + +func validEnvKey(key string) bool { + if key == "" { + return false + } + for i := 0; i < len(key); i++ { + c := key[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_': + case c >= '0' && c <= '9': + if i == 0 { + return false + } + default: + return false + } + } + return true +} + +// commentIndex finds an inline comment start (a # preceded by whitespace) +// outside a quoted value; -1 if none. +func commentIndex(value string) int { + var quote byte + for i := 0; i < len(value); i++ { + c := value[i] + switch { + case quote != 0: + if c == quote { + quote = 0 + } + case c == '"' || c == '\'': + quote = c + case c == '#' && (i == 0 || value[i-1] == ' ' || value[i-1] == ' '): + return i + } + } + return -1 +} + +func unquote(v string) string { + if len(v) >= 2 { + if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') { + return v[1 : len(v)-1] + } + } + return v +} + +func pathOrDefault(path string) string { + if path != "" { + return path + } + return "the --config env file" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2782695 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,107 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, name string, mode os.FileMode, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadEnvOnly(t *testing.T) { + t.Setenv("MRED_URL", "https://redmine.example/") + t.Setenv("MRED_KEY", "k1") + cfg, src, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.BaseURL != "https://redmine.example" { + t.Errorf("BaseURL = %q, want trailing slash trimmed", cfg.BaseURL) + } + if cfg.APIKey != "k1" { + t.Errorf("APIKey = %q", cfg.APIKey) + } + if src.Env == "" { + t.Errorf("source env = %q, want a key name", src.Env) + } +} + +func TestLoadFileOnly(t *testing.T) { + path := writeFile(t, "env", 0o600, "MRED_URL=https://r.example\nMRED_KEY=k2\n") + cfg, _, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.BaseURL != "https://r.example" || cfg.APIKey != "k2" { + t.Errorf("cfg = %+v", cfg) + } +} + +func TestLoadEnvWinsOverFile(t *testing.T) { + t.Setenv("MRED_KEY", "envkey") + path := writeFile(t, "env", 0o600, "MRED_URL=https://r.example\nMRED_KEY=filekey\n") + cfg, src, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.APIKey != "envkey" { + t.Errorf("APIKey = %q, want env value to win", cfg.APIKey) + } + if src.File != "" && src.Env != "MRED_KEY" { + t.Errorf("source = %+v", src) + } +} + +func TestLoadRejectsLooseFile(t *testing.T) { + path := writeFile(t, "env", 0o644, "MRED_URL=https://r.example\nMRED_KEY=k\n") + _, _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "insecure mode") { + t.Fatalf("err = %v, want insecure-mode rejection", err) + } +} + +func TestLoadMissingEverything(t *testing.T) { + _, _, err := Load(filepath.Join(t.TempDir(), "absent")) + if err == nil || !strings.Contains(err.Error(), "MRED_URL") { + t.Fatalf("err = %v, want guidance naming MRED_URL/MRED_KEY", err) + } +} + +func TestLoadKeyAbsent(t *testing.T) { + t.Setenv("MRED_URL", "https://r.example") + path := writeFile(t, "env", 0o600, "# nothing useful\n") + _, _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "MRED_KEY") { + t.Fatalf("err = %v, want MRED_KEY named", err) + } +} + +func TestLoadMalformedLineReportsNumberOnly(t *testing.T) { + path := writeFile(t, "env", 0o600, "MRED_URL=https://r.example\nMRED_KEY=sekrit-value\nBROKEN LINE HERE\n") + _, _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "line 3") { + t.Fatalf("err = %v, want line-number-only diagnostic", err) + } + if err != nil && strings.Contains(err.Error(), "sekrit-value") { + t.Fatalf("err = %v leaks file contents", err) + } +} + +func TestLoadQuotedAndExported(t *testing.T) { + path := writeFile(t, "env", 0o600, "export MRED_URL=\"https://r.example\"\nexport MRED_KEY='k3'\n") + cfg, _, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.BaseURL != "https://r.example" || cfg.APIKey != "k3" { + t.Errorf("cfg = %+v, want quotes stripped", cfg) + } +}