From b2891613796ca82d9d50c7d60db80593415d1176 Mon Sep 17 00:00:00 2001 From: winlin Date: Sun, 12 Apr 2026 13:40:55 -0400 Subject: [PATCH] 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) --- go.mod | 5 +- go.sum | 2 - internal/env/env.go | 90 +++++++++++++++- internal/env/env_test.go | 223 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 10 deletions(-) create mode 100644 internal/env/env_test.go diff --git a/go.mod b/go.mod index bf009f16c..709a04b48 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index ee3c64abd..17906ec30 100644 --- a/go.sum +++ b/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= diff --git a/internal/env/env.go b/internal/env/env.go index 791580705..35507089a 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -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. diff --git a/internal/env/env_test.go b/internal/env/env_test.go new file mode 100644 index 000000000..6854a2a34 --- /dev/null +++ b/internal/env/env_test.go @@ -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 +}