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
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user