180 lines
4.9 KiB
Go
180 lines
4.9 KiB
Go
// Package secrets parses tfvars-style files of `NAME = "value"` lines into
|
|
// GitHub Actions secret name/value pairs.
|
|
package secrets
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Pair is a single parsed secret.
|
|
type Pair struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
// EnvFile groups secrets parsed from one <env>.tfvars file.
|
|
type EnvFile struct {
|
|
Env string
|
|
Source string
|
|
Secrets []Pair
|
|
}
|
|
|
|
var (
|
|
nameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
lineRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$`)
|
|
envRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
|
|
)
|
|
|
|
// IsValidGitHubSecretName reports whether n is a valid GitHub Actions secret
|
|
// name (alphanumerics + underscore, no leading digit, no GITHUB_ prefix).
|
|
func IsValidGitHubSecretName(n string) bool {
|
|
if !nameRe.MatchString(n) {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(n, "GITHUB_") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ParseFile reads a tfvars-style secrets file and returns the parsed pairs in
|
|
// declaration order. Comments (# or //) and blank lines are ignored. Values
|
|
// may be double-quoted (with `\\ \" \n \r \t` escapes) or single-quoted (raw).
|
|
// Duplicate names within a single file are an error.
|
|
func ParseFile(path string) ([]Pair, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("secrets file not found: %s", path)
|
|
}
|
|
return nil, fmt.Errorf("secrets file not readable: %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var pairs []Pair
|
|
seen := map[string]struct{}{}
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
lineno := 0
|
|
for scanner.Scan() {
|
|
lineno++
|
|
line := strings.TrimRight(scanner.Text(), "\r")
|
|
line = strings.TrimLeft(line, " \t")
|
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
|
continue
|
|
}
|
|
m := lineRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return nil, fmt.Errorf(`%s:%d: cannot parse line (expected NAME = "value"): %s`, path, lineno, line)
|
|
}
|
|
name := m[1]
|
|
rest := strings.TrimRight(m[2], " \t")
|
|
val, err := decodeValue(rest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s:%d: %v", path, lineno, err)
|
|
}
|
|
if !IsValidGitHubSecretName(name) {
|
|
return nil, fmt.Errorf("%s:%d: invalid GitHub secret name %q (alphanumerics + underscore, no leading digit, no GITHUB_ prefix)", path, lineno, name)
|
|
}
|
|
if _, dup := seen[name]; dup {
|
|
return nil, fmt.Errorf("duplicate secret %q in %s", name, path)
|
|
}
|
|
seen[name] = struct{}{}
|
|
pairs = append(pairs, Pair{Name: name, Value: val})
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", path, err)
|
|
}
|
|
if len(pairs) == 0 {
|
|
return nil, fmt.Errorf("%s: no secrets found", path)
|
|
}
|
|
return pairs, nil
|
|
}
|
|
|
|
func decodeValue(rest string) (string, error) {
|
|
if len(rest) >= 2 && rest[0] == '"' && rest[len(rest)-1] == '"' {
|
|
inner := rest[1 : len(rest)-1]
|
|
return decodeDoubleQuoted(inner), nil
|
|
}
|
|
if len(rest) >= 2 && rest[0] == '\'' && rest[len(rest)-1] == '\'' {
|
|
return rest[1 : len(rest)-1], nil
|
|
}
|
|
return "", fmt.Errorf("value must be a single quoted string")
|
|
}
|
|
|
|
func decodeDoubleQuoted(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c == '\\' && i+1 < len(s) {
|
|
switch s[i+1] {
|
|
case '\\':
|
|
b.WriteByte('\\')
|
|
case '"':
|
|
b.WriteByte('"')
|
|
case 'n':
|
|
b.WriteByte('\n')
|
|
case 'r':
|
|
b.WriteByte('\r')
|
|
case 't':
|
|
b.WriteByte('\t')
|
|
default:
|
|
b.WriteByte(c)
|
|
b.WriteByte(s[i+1])
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
b.WriteByte(c)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// LoadEnvDir loads every *.tfvars file in dir. Each basename (without
|
|
// extension) must be one of envSet. The list is sorted by env name so output
|
|
// is deterministic.
|
|
func LoadEnvDir(dir string, envSet map[string]struct{}) ([]EnvFile, error) {
|
|
info, err := os.Stat(dir)
|
|
if err != nil || !info.IsDir() {
|
|
return nil, fmt.Errorf("env-secrets dir not found: %s", dir)
|
|
}
|
|
matches, err := filepath.Glob(filepath.Join(dir, "*.tfvars"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(matches) == 0 {
|
|
return nil, fmt.Errorf("no *.tfvars files in %s", dir)
|
|
}
|
|
sort.Strings(matches)
|
|
|
|
declared := make([]string, 0, len(envSet))
|
|
for e := range envSet {
|
|
declared = append(declared, e)
|
|
}
|
|
sort.Strings(declared)
|
|
|
|
out := make([]EnvFile, 0, len(matches))
|
|
for _, ef := range matches {
|
|
base := strings.TrimSuffix(filepath.Base(ef), ".tfvars")
|
|
if !envRe.MatchString(base) {
|
|
return nil, fmt.Errorf("invalid env name derived from filename: %s (basename must match [A-Za-z][A-Za-z0-9_-]*)", ef)
|
|
}
|
|
if _, ok := envSet[base]; !ok {
|
|
return nil, fmt.Errorf("env-secrets file %q targets env %q which is not in --env list (%s)", ef, base, strings.Join(declared, " "))
|
|
}
|
|
pairs, err := ParseFile(ef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, EnvFile{Env: base, Source: ef, Secrets: pairs})
|
|
}
|
|
return out, nil
|
|
}
|