feat(config): GITEA_URL/GITEA_KEY env + 0600 env-file loading
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
// Package config loads Gitea connection settings for the mgit CLI.
|
||||
// The base URL and token arrive ONLY from (a) GITEA_URL / GITEA_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 token is never logged or echoed.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Keys understood in the environment and the env file.
|
||||
const (
|
||||
KeyURL = "GITEA_URL"
|
||||
KeyKey = "GITEA_KEY"
|
||||
)
|
||||
|
||||
// Config is the resolved connection set.
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
Token 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 GITEA_URL and GITEA_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.Token = v
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if cfg.BaseURL == "" {
|
||||
missing = append(missing, KeyURL)
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
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-redmine-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"
|
||||
}
|
||||
Reference in New Issue
Block a user