Replace godotenv with custom .env parser and add unit tests
Remove the third-party godotenv dependency by inlining a minimal .env file parser that supports the same syntax we use (KEY=VALUE, comments, export prefix, single/double quoted values). Add 13 unit tests covering all parsing paths and the no-overwrite behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c17c93b70
commit
b289161379
5
go.mod
5
go.mod
|
|
@ -2,10 +2,7 @@ module srsx
|
|||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
require github.com/go-redis/redis/v8 v8.11.5
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -6,8 +6,6 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
|
|||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
|
|
|
|||
90
internal/env/env.go
vendored
90
internal/env/env.go
vendored
|
|
@ -4,10 +4,10 @@
|
|||
package env
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"strings"
|
||||
|
||||
"srsx/internal/errors"
|
||||
"srsx/internal/logger"
|
||||
|
|
@ -162,18 +162,100 @@ func (e *environment) DefaultBackendSRT() string {
|
|||
|
||||
// loadEnvFile loads the environment variables from .env file.
|
||||
func loadEnvFile(ctx context.Context) error {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
// If .env file doesn't exist, that's okay, just log and continue
|
||||
envMap, err := parseEnvFile(".env")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logger.Df(ctx, "no .env file found, skipping")
|
||||
return nil
|
||||
}
|
||||
return errors.Wrapf(err, "load .env file")
|
||||
}
|
||||
|
||||
// Build a set of existing environment variable keys, so we don't overwrite them.
|
||||
currentEnv := make(map[string]bool)
|
||||
for _, entry := range os.Environ() {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
currentEnv[key] = true
|
||||
}
|
||||
|
||||
for key, value := range envMap {
|
||||
if !currentEnv[key] {
|
||||
os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Df(ctx, "successfully loaded .env file")
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseEnvFile reads a .env file and returns a map of key-value pairs.
|
||||
func parseEnvFile(filename string) (map[string]string, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
envMap := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and comments.
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip optional "export " prefix.
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
line = strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
// Split on first '=' to get key and value.
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Handle quoted values.
|
||||
if len(value) >= 2 {
|
||||
if value[0] == '\'' && value[len(value)-1] == '\'' {
|
||||
// Single-quoted: raw literal, no escaping.
|
||||
value = value[1 : len(value)-1]
|
||||
} else if value[0] == '"' && value[len(value)-1] == '"' {
|
||||
// Double-quoted: process escape sequences.
|
||||
value = value[1 : len(value)-1]
|
||||
value = strings.ReplaceAll(value, `\n`, "\n")
|
||||
value = strings.ReplaceAll(value, `\r`, "\r")
|
||||
value = strings.ReplaceAll(value, `\"`, `"`)
|
||||
value = strings.ReplaceAll(value, `\\`, `\`)
|
||||
} else {
|
||||
// Unquoted: strip inline comments.
|
||||
if idx := strings.Index(value, " #"); idx != -1 {
|
||||
value = strings.TrimSpace(value[:idx])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unquoted short value: strip inline comments.
|
||||
if idx := strings.Index(value, " #"); idx != -1 {
|
||||
value = strings.TrimSpace(value[:idx])
|
||||
}
|
||||
}
|
||||
|
||||
envMap[key] = value
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return envMap, nil
|
||||
}
|
||||
|
||||
// buildDefaultEnvironmentVariables setups the default environment variables.
|
||||
func buildDefaultEnvironmentVariables(ctx context.Context) {
|
||||
// Whether enable the Go pprof.
|
||||
|
|
|
|||
223
internal/env/env_test.go
vendored
Normal file
223
internal/env/env_test.go
vendored
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// Copyright (c) 2025 Winlin
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
package env
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEnvFile_BasicKeyValue(t *testing.T) {
|
||||
f := writeTempEnv(t, "FOO=bar\nHELLO=world\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["FOO"] != "bar" {
|
||||
t.Errorf("FOO = %q, want %q", m["FOO"], "bar")
|
||||
}
|
||||
if m["HELLO"] != "world" {
|
||||
t.Errorf("HELLO = %q, want %q", m["HELLO"], "world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_SkipCommentsAndBlankLines(t *testing.T) {
|
||||
f := writeTempEnv(t, "# this is a comment\n\nKEY=value\n\n# another comment\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(m) != 1 {
|
||||
t.Errorf("got %d entries, want 1", len(m))
|
||||
}
|
||||
if m["KEY"] != "value" {
|
||||
t.Errorf("KEY = %q, want %q", m["KEY"], "value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_ExportPrefix(t *testing.T) {
|
||||
f := writeTempEnv(t, "export PORT=8080\nexport HOST=localhost\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["PORT"] != "8080" {
|
||||
t.Errorf("PORT = %q, want %q", m["PORT"], "8080")
|
||||
}
|
||||
if m["HOST"] != "localhost" {
|
||||
t.Errorf("HOST = %q, want %q", m["HOST"], "localhost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_SingleQuoted(t *testing.T) {
|
||||
f := writeTempEnv(t, "KEY='hello world'\nRAW='no\\nescaping'\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["KEY"] != "hello world" {
|
||||
t.Errorf("KEY = %q, want %q", m["KEY"], "hello world")
|
||||
}
|
||||
// Single quotes: backslash-n stays literal.
|
||||
if m["RAW"] != `no\nescaping` {
|
||||
t.Errorf("RAW = %q, want %q", m["RAW"], `no\nescaping`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_DoubleQuoted(t *testing.T) {
|
||||
f := writeTempEnv(t, `KEY="hello world"`+"\n"+`MSG="line1\nline2"`+"\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["KEY"] != "hello world" {
|
||||
t.Errorf("KEY = %q, want %q", m["KEY"], "hello world")
|
||||
}
|
||||
if m["MSG"] != "line1\nline2" {
|
||||
t.Errorf("MSG = %q, want %q", m["MSG"], "line1\nline2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_DoubleQuotedEscapes(t *testing.T) {
|
||||
f := writeTempEnv(t, `KEY="say \"hi\""`+"\n"+`BS="back\\slash"`+"\n"+`CR="a\rb"`+"\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["KEY"] != `say "hi"` {
|
||||
t.Errorf("KEY = %q, want %q", m["KEY"], `say "hi"`)
|
||||
}
|
||||
if m["BS"] != `back\slash` {
|
||||
t.Errorf("BS = %q, want %q", m["BS"], `back\slash`)
|
||||
}
|
||||
if m["CR"] != "a\rb" {
|
||||
t.Errorf("CR = %q, want %q", m["CR"], "a\rb")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_InlineComment(t *testing.T) {
|
||||
f := writeTempEnv(t, "KEY=value # this is a comment\nNUM=42 # the answer\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["KEY"] != "value" {
|
||||
t.Errorf("KEY = %q, want %q", m["KEY"], "value")
|
||||
}
|
||||
if m["NUM"] != "42" {
|
||||
t.Errorf("NUM = %q, want %q", m["NUM"], "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_NoEqualsSign(t *testing.T) {
|
||||
f := writeTempEnv(t, "NOEQUALS\nKEY=value\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(m) != 1 {
|
||||
t.Errorf("got %d entries, want 1", len(m))
|
||||
}
|
||||
if m["KEY"] != "value" {
|
||||
t.Errorf("KEY = %q, want %q", m["KEY"], "value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_EmptyValue(t *testing.T) {
|
||||
f := writeTempEnv(t, "KEY=\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := m["KEY"]; !ok || v != "" {
|
||||
t.Errorf("KEY = %q (ok=%v), want empty string", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_ValueWithEquals(t *testing.T) {
|
||||
f := writeTempEnv(t, "URL=postgres://host:5432/db?opt=val\n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["URL"] != "postgres://host:5432/db?opt=val" {
|
||||
t.Errorf("URL = %q, want %q", m["URL"], "postgres://host:5432/db?opt=val")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_FileNotFound(t *testing.T) {
|
||||
_, err := parseEnvFile("/nonexistent/.env")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expected os.IsNotExist, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_WhitespaceAroundKeyValue(t *testing.T) {
|
||||
f := writeTempEnv(t, " KEY = value \n")
|
||||
m, err := parseEnvFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["KEY"] != "value" {
|
||||
t.Errorf("KEY = %q, want %q", m["KEY"], "value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvFile_DoesNotOverwriteExisting(t *testing.T) {
|
||||
// Write a .env file in a temp dir.
|
||||
dir := t.TempDir()
|
||||
envFile := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envFile, []byte("TEST_EXISTING=fromfile\nTEST_NEW=fromfile\n"), 0644); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
// Pre-set one of the keys in the real environment.
|
||||
os.Setenv("TEST_EXISTING", "fromshell")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("TEST_EXISTING")
|
||||
os.Unsetenv("TEST_NEW")
|
||||
})
|
||||
|
||||
// Parse and apply, mimicking loadEnvFile logic.
|
||||
m, err := parseEnvFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
currentEnv := make(map[string]bool)
|
||||
for _, entry := range os.Environ() {
|
||||
k, _, _ := strings.Cut(entry, "=")
|
||||
currentEnv[k] = true
|
||||
}
|
||||
for k, v := range m {
|
||||
if !currentEnv[k] {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing key should NOT be overwritten.
|
||||
if got := os.Getenv("TEST_EXISTING"); got != "fromshell" {
|
||||
t.Errorf("TEST_EXISTING = %q, want %q (should not overwrite)", got, "fromshell")
|
||||
}
|
||||
// New key should be set.
|
||||
if got := os.Getenv("TEST_NEW"); got != "fromfile" {
|
||||
t.Errorf("TEST_NEW = %q, want %q", got, "fromfile")
|
||||
}
|
||||
}
|
||||
|
||||
// writeTempEnv writes content to a temp .env file and returns the path.
|
||||
func writeTempEnv(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(f, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write temp .env: %v", err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user