diff --git a/docs/config.md b/docs/config.md index 2033792..45936c4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,7 +18,7 @@ Precedence is: The implemented configuration overrides are `--units` and `--tz`. Output flags control report copies for the current command but do not change configuration -files. Environment-variable configuration is not implemented. +files. Environment variables do not override configuration fields. ## Minimal Config @@ -64,6 +64,18 @@ multiple configured forecast locations. The prompt-facing location object also includes `timezone`, derived from the effective `weather_api.timezone` after CLI overrides such as `--tz`. +### `secrets` + +- `directory`: optional directory of file-backed environment secrets. Default: + empty, which disables secret loading. + +When configured, each regular file directly under `secrets.directory` is loaded +after config file parsing and CLI overrides. The file basename must be a valid +environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents +become the environment variable value and overwrite any existing value. One +trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames, +missing directories, and unreadable files fail config loading. + ### `missing_source` - `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`. @@ -115,8 +127,9 @@ snapshot exists and a threshold is crossed. ## Secrets -Configuration files should not contain secrets. The current Weather API and -Scriptorium integration settings do not require secret fields. +Configuration files should not contain raw secrets. Use `secrets.directory` to +load secret values from files into environment variables for integrations that +read credentials from the environment. ## Maintained Examples diff --git a/examples/config.yml b/examples/config.yml index 083bdb6..05b175a 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -11,6 +11,9 @@ location: name: Brentwood region: St. Louis Metro +secrets: + directory: "" + missing_source: default: warn sources: diff --git a/internal/config/config.go b/internal/config/config.go index 2972bf4..1497c10 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ const ( type Config struct { WeatherAPI WeatherAPIConfig `yaml:"weather_api"` Location LocationConfig `yaml:"location"` + Secrets SecretsConfig `yaml:"secrets"` MissingSource MissingSourceConfig `yaml:"missing_source"` Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Workspace WorkspaceConfig `yaml:"workspace"` @@ -37,6 +38,10 @@ type LocationConfig struct { Region string `yaml:"region"` } +type SecretsConfig struct { + Directory string `yaml:"directory"` +} + type MissingSourceConfig struct { Default MissingSourcePolicy `yaml:"default"` Sources map[string]MissingSourcePolicy `yaml:"sources"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c6b7f60..a2b33a9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,6 +26,9 @@ func TestDefaults(t *testing.T) { if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location) } + if cfg.Secrets.Directory != "" { + t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory) + } if cfg.MissingSource.Default != MissingSourceWarn { t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default) } @@ -112,3 +115,167 @@ func TestLoadAppliesOverrides(t *testing.T) { t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone) } } + +func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) { + t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original") + + if err := loadSecrets(SecretsConfig{}); err != nil { + t.Fatalf("loadSecrets() error = %v", err) + } + if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" { + t.Fatalf("environment value = %q, want original", got) + } +} + +func TestLoadFileLoadsSecretsDirectory(t *testing.T) { + dir := t.TempDir() + secretsDir := filepath.Join(dir, "secrets") + if err := os.Mkdir(secretsDir, 0o700); err != nil { + t.Fatalf("create secrets directory: %v", err) + } + if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil { + t.Fatalf("write secret: %v", err) + } + path := filepath.Join(dir, "config.yml") + if err := os.WriteFile(path, []byte("secrets:\n directory: "+secretsDir+"\n"), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + + t.Setenv("WEATHERREPORTER_SECRET", "") + if _, err := LoadFile(path); err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" { + t.Fatalf("environment value = %q, want from-file", got) + } +} + +func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil { + t.Fatalf("write secret: %v", err) + } + t.Setenv("WEATHERREPORTER_SECRET", "existing") + + if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil { + t.Fatalf("loadSecrets() error = %v", err) + } + if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" { + t.Fatalf("environment value = %q, want from-file", got) + } +} + +func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "LF", input: "value\n", want: "value"}, + {name: "CRLF", input: "value\r\n", want: "value"}, + {name: "TwoLF", input: "value\n\n", want: "value\n"}, + {name: "LoneCR", input: "value\r", want: "value\r"}, + {name: "NoNewline", input: "value", want: "value"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte(tt.input), 0o600); err != nil { + t.Fatalf("write secret: %v", err) + } + t.Setenv("WEATHERREPORTER_SECRET", "") + + if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil { + t.Fatalf("loadSecrets() error = %v", err) + } + if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want { + t.Fatalf("environment value = %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + wantErr string + }{ + { + name: "InvalidFilename", + setup: func(t *testing.T, dir string) { + if err := os.WriteFile(filepath.Join(dir, "1INVALID"), []byte("secret-value"), 0o600); err != nil { + t.Fatalf("write invalid secret: %v", err) + } + }, + wantErr: "invalid environment variable name", + }, + { + name: "Subdirectory", + setup: func(t *testing.T, dir string) { + if err := os.Mkdir(filepath.Join(dir, "SUBDIR"), 0o700); err != nil { + t.Fatalf("create subdirectory: %v", err) + } + }, + wantErr: "not a directory", + }, + { + name: "Symlink", + setup: func(t *testing.T, dir string) { + target := filepath.Join(dir, "TARGET") + if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil { + t.Fatalf("create symlink: %v", err) + } + }, + wantErr: "not a symlink", + }, + { + name: "Unreadable", + setup: func(t *testing.T, dir string) { + path := filepath.Join(dir, "UNREADABLE") + if err := os.WriteFile(path, []byte("secret-value"), 0o600); err != nil { + t.Fatalf("write unreadable secret: %v", err) + } + if err := os.Chmod(path, 0o000); err != nil { + t.Fatalf("chmod unreadable secret: %v", err) + } + t.Cleanup(func() { + _ = os.Chmod(path, 0o600) + }) + }, + wantErr: "read secret file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + err := loadSecrets(SecretsConfig{Directory: dir}) + if err == nil { + t.Fatal("loadSecrets() error = nil, want error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) + } + if strings.Contains(err.Error(), "secret-value") { + t.Fatalf("error = %q, want no secret value", err.Error()) + } + }) + } +} + +func TestLoadSecretsRejectsMissingDirectory(t *testing.T) { + err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")}) + if err == nil { + t.Fatal("loadSecrets() error = nil, want missing directory error") + } + if !strings.Contains(err.Error(), "read secrets directory") { + t.Fatalf("error = %q, want read secrets directory context", err.Error()) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index ddb9803..1f622ac 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -18,6 +18,9 @@ func Defaults() Config { Name: "Brentwood", Region: "St. Louis Metro", }, + Secrets: SecretsConfig{ + Directory: "", + }, MissingSource: MissingSourceConfig{ Default: MissingSourceWarn, Sources: map[string]MissingSourcePolicy{}, diff --git a/internal/config/load.go b/internal/config/load.go index 829bb55..cd8b190 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -35,6 +35,10 @@ func Load(opts LoadOptions) (Config, error) { cfg.WeatherAPI.Timezone = opts.Timezone } + if err := loadSecrets(cfg.Secrets); err != nil { + return Config{}, err + } + if err := Validate(cfg); err != nil { return Config{}, err } diff --git a/internal/config/secrets.go b/internal/config/secrets.go new file mode 100644 index 0000000..9509b67 --- /dev/null +++ b/internal/config/secrets.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func loadSecrets(cfg SecretsConfig) error { + if cfg.Directory == "" { + return nil + } + + entries, err := os.ReadDir(cfg.Directory) + if err != nil { + return fmt.Errorf("read secrets directory %q: %w", cfg.Directory, err) + } + + for _, entry := range entries { + name := entry.Name() + if name == "" { + return fmt.Errorf("secrets directory %q contains an empty filename", cfg.Directory) + } + if !secretNamePattern.MatchString(name) { + return fmt.Errorf("secret file %q has invalid environment variable name", name) + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("secret file %q must be a regular file, not a symlink", name) + } + if entry.IsDir() { + return fmt.Errorf("secret file %q must be a regular file, not a directory", name) + } + + info, err := entry.Info() + if err != nil { + return fmt.Errorf("inspect secret file %q: %w", name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("secret file %q must be a regular file", name) + } + + path := filepath.Join(cfg.Directory, name) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read secret file %q: %w", name, err) + } + value := string(data) + if strings.HasSuffix(value, "\r\n") { + value = strings.TrimSuffix(value, "\r\n") + } else { + value = strings.TrimSuffix(value, "\n") + } + if err := os.Setenv(name, value); err != nil { + return fmt.Errorf("set environment variable from secret file %q: %w", name, err) + } + } + + return nil +}