Files
mrcharles b43bc55963 Add backend resolver interface with file and env backends plus phase-3 stubs
Introduce the single Backend interface every credential source implements.
v0 ships two working backends: file (0600 KEY=VALUE env files, parsed in
pure Go, never sourced; looser permission masks refused before read) and
env (process-environment indirection). Bitwarden Secrets Manager and
HashiCorp Vault ship as explicit not-implemented stubs behind the same
interface so the phase-3 connectors are drop-ins. All failures are typed
ResolveErrors carrying only the ref, backend, and a fixed reason enum —
never material.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-28 22:30:50 -05:00

103 lines
2.8 KiB
Go

package backend
import (
"fmt"
"strings"
)
// parseEnvFile parses KEY=VALUE lines in pure Go. Env files are NEVER
// sourced or eval'd (house rule: no exec of env files); there is no
// shell expansion, no command substitution, no interpolation of any
// kind. Accepted syntax per line:
//
// # comment skipped (also inline comments after whitespace)
// (blank line) skipped
// export KEY=VALUE optional "export " prefix, skipped
// KEY=VALUE KEY is [A-Za-z_][A-Za-z0-9_]*; VALUE is the rest
// of the line, trimmed; one matched pair of
// surrounding single or double quotes is stripped
// (no escape processing)
//
// A later duplicate KEY overrides an earlier one (same order semantics
// as sourcing the file). Malformed lines fail loudly: the error carries
// the line NUMBER only, never the line contents (contents may be
// material).
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
}
// validEnvKey reports whether key is a legal env-file identifier.
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] == '\t'):
return i
}
}
return -1
}
// unquote strips one matched pair of surrounding quotes.
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
}