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
150 lines
4.6 KiB
Go
150 lines
4.6 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const testMaterial = "sk-live-supersecret-material-0123456789"
|
|
|
|
// writeEnvFile writes an env file with the given permission mask.
|
|
func writeEnvFile(t *testing.T, dir, name, content string, mode os.FileMode) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestFileResolve(t *testing.T) {
|
|
dir := t.TempDir()
|
|
secretFile := writeEnvFile(t, dir, "creds.env", "API_KEY="+testMaterial+"\nOTHER=x\n", 0o600)
|
|
emptyFile := writeEnvFile(t, dir, "empty.env", "API_KEY=\n", 0o600)
|
|
looseFile := writeEnvFile(t, dir, "loose.env", "API_KEY="+testMaterial+"\n", 0o644)
|
|
strictFile := writeEnvFile(t, dir, "strict.env", "API_KEY="+testMaterial+"\n", 0o400)
|
|
groupFile := writeEnvFile(t, dir, "group.env", "API_KEY="+testMaterial+"\n", 0o640)
|
|
brokenFile := writeEnvFile(t, dir, "broken.env", "this line has no equals\n", 0o600)
|
|
|
|
tests := []struct {
|
|
name string
|
|
ref Ref
|
|
wantValue string
|
|
wantErr string // substring of the ResolveError message
|
|
}{
|
|
{
|
|
name: "0600 file resolves",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "API_KEY"},
|
|
wantValue: testMaterial,
|
|
},
|
|
{
|
|
name: "0400 stricter than mask passes",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: strictFile, Key: "API_KEY"},
|
|
wantValue: testMaterial,
|
|
},
|
|
{
|
|
name: "0644 refused insecure mode",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: looseFile, Key: "API_KEY"},
|
|
wantErr: "insecure_source_mode",
|
|
},
|
|
{
|
|
name: "0640 refused under default 0600 mask",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: groupFile, Key: "API_KEY"},
|
|
wantErr: "insecure_source_mode",
|
|
},
|
|
{
|
|
name: "0640 passes when mask says 0640",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: groupFile, Key: "API_KEY", Mode: "0640"},
|
|
wantValue: testMaterial,
|
|
},
|
|
{
|
|
name: "missing file",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: filepath.Join(dir, "nope.env"), Key: "API_KEY"},
|
|
wantErr: "unreadable_source",
|
|
},
|
|
{
|
|
name: "directory as source",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: dir, Key: "API_KEY"},
|
|
wantErr: "unreadable_source",
|
|
},
|
|
{
|
|
name: "missing key",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "NOT_THERE"},
|
|
wantErr: "missing_key",
|
|
},
|
|
{
|
|
name: "empty value",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: emptyFile, Key: "API_KEY"},
|
|
wantErr: "empty_value",
|
|
},
|
|
{
|
|
name: "malformed file",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: brokenFile, Key: "API_KEY"},
|
|
wantErr: "malformed_source",
|
|
},
|
|
{
|
|
name: "mode mask fails closed on junk",
|
|
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "API_KEY", Mode: "junk"},
|
|
wantErr: "insecure_source_mode",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f := NewFile()
|
|
got, err := f.Resolve(context.Background(), tt.ref)
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("want error %q, got value", tt.wantErr)
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, tt.wantErr) {
|
|
t.Fatalf("error %q does not contain %q", msg, tt.wantErr)
|
|
}
|
|
assertRedactedError(t, msg, tt.ref.Name, "file", testMaterial)
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.wantValue {
|
|
t.Fatalf("got %q, want %q", got, tt.wantValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFileHomeExpansion covers ~/ expansion (generic home indirection,
|
|
// not an org-specific path).
|
|
func TestFileHomeExpansion(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
dir, _ := os.LookupEnv("HOME")
|
|
path := writeEnvFile(t, dir, "creds.env", "API_KEY="+testMaterial+"\n", 0o600)
|
|
f := NewFile()
|
|
got, err := f.Resolve(context.Background(), Ref{Name: "mpk-home", Backend: "file", Source: "~/creds.env", Key: "API_KEY"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != testMaterial {
|
|
t.Fatalf("got %q, want the material", got)
|
|
}
|
|
_ = path
|
|
}
|
|
|
|
// assertRedactedError enforces the house rule: resolution failures name
|
|
// the ref and the backend, NEVER material.
|
|
func assertRedactedError(t *testing.T, msg, refName, backendName, material string) {
|
|
t.Helper()
|
|
if !strings.Contains(msg, refName) || !strings.Contains(msg, backendName) {
|
|
t.Fatalf("error %q must name ref %q and backend %q", msg, refName, backendName)
|
|
}
|
|
if strings.Contains(msg, material) {
|
|
t.Fatalf("ERROR LEAKS MATERIAL: %q", msg)
|
|
}
|
|
}
|