ultisuite-backend/internal/envexpand/expand.go

246 lines
5.6 KiB
Go

package envexpand
import (
"bufio"
"fmt"
"os"
"regexp"
"strings"
)
var placeholderRE = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`)
const maxIterations = 32
// ExpandString replaces {{NAME}} placeholders using vars.
func ExpandString(s string, vars map[string]string) string {
return placeholderRE.ReplaceAllStringFunc(s, func(match string) string {
name := match[2 : len(match)-2]
if v, ok := vars[name]; ok {
return v
}
return match
})
}
// ExpandMap resolves {{VAR}} references until stable or maxIterations is reached.
func ExpandMap(vars map[string]string) (map[string]string, error) {
out := make(map[string]string, len(vars))
for k, v := range vars {
out[k] = v
}
for i := 0; i < maxIterations; i++ {
changed := false
for k, v := range out {
next := ExpandString(v, out)
if next != v {
changed = true
out[k] = next
}
}
if !changed {
if unresolved := findUnresolved(out); len(unresolved) > 0 {
return nil, fmt.Errorf("envexpand: unresolved placeholders: %s", strings.Join(unresolved, ", "))
}
return out, nil
}
}
return nil, fmt.Errorf("envexpand: expansion did not converge after %d iterations", maxIterations)
}
func findUnresolved(vars map[string]string) []string {
seen := make(map[string]bool)
for _, v := range vars {
for _, name := range placeholderRE.FindAllStringSubmatch(v, -1) {
seen[name[1]] = true
}
}
names := make([]string, 0, len(seen))
for n := range seen {
names = append(names, "{{"+n+"}}")
}
for i := 0; i < len(names); i++ {
for j := i + 1; j < len(names); j++ {
if names[j] < names[i] {
names[i], names[j] = names[j], names[i]
}
}
}
return names
}
// ParseLines reads KEY=VALUE pairs from dotenv-style content (no expansion).
func ParseLines(lines []string) (map[string]string, error) {
vars := make(map[string]string)
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("envexpand: invalid line %d: missing '='", i+1)
}
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("envexpand: invalid line %d: empty key", i+1)
}
value = strings.TrimSpace(value)
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
vars[key] = value
}
return vars, nil
}
// LoadFile parses a .env file without expanding placeholders.
func LoadFile(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
return ParseLines(lines)
}
// LoadExpandFile parses and expands a .env file.
func LoadExpandFile(path string) (map[string]string, error) {
vars, err := LoadFile(path)
if err != nil {
return nil, err
}
return ExpandMap(vars)
}
// WriteFile writes KEY=VALUE lines (no quoting; values are used as-is).
func WriteFile(path string, vars map[string]string) error {
var keys []string
for k := range vars {
keys = append(keys, k)
}
// Stable output: sort keys
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteByte('=')
b.WriteString(vars[k])
b.WriteByte('\n')
}
return os.WriteFile(path, []byte(b.String()), 0o600)
}
// ApplyFile loads and expands a .env file, then sets variables in the process
// environment. Existing environment variables are not overwritten.
func ApplyFile(path string) error {
vars, err := LoadExpandFile(path)
if err != nil {
return err
}
ApplyMap(vars, false)
return nil
}
// ApplyMap sets variables in the process environment.
// When override is false, existing environment variables are kept.
func ApplyMap(vars map[string]string, override bool) {
for k, v := range vars {
if !override {
if _, exists := os.LookupEnv(k); exists {
continue
}
}
_ = os.Setenv(k, v)
}
}
// Render reads an input .env, expands placeholders, and writes the result.
func Render(inPath, outPath string) error {
vars, err := LoadExpandFile(inPath)
if err != nil {
return err
}
return WriteFile(outPath, vars)
}
// RenderToWriter expands and writes dotenv format to w.
func RenderToWriter(inPath string, w interface{ Write([]byte) (int, error) }) error {
vars, err := LoadExpandFile(inPath)
if err != nil {
return err
}
var keys []string
for k := range vars {
keys = append(keys, k)
}
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
for _, k := range keys {
if _, err := fmt.Fprintf(w, "%s=%s\n", k, vars[k]); err != nil {
return err
}
}
return nil
}
// ParseReader is a helper for tests and stdin.
func ParseReader(r interface {
ReadString(byte) (string, error)
}) (map[string]string, error) {
var lines []string
for {
line, err := r.ReadString('\n')
lines = append(lines, strings.TrimSuffix(line, "\n"))
if err != nil {
break
}
}
return ParseLines(lines)
}
// ParseFileLines loads via bufio for large files (alias to LoadFile).
func ParseFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var lines []string
sc := bufio.NewScanner(f)
for sc.Scan() {
lines = append(lines, sc.Text())
}
if err := sc.Err(); err != nil {
return nil, err
}
return ParseLines(lines)
}