From 0affe392a51b9eac0b9df93f8f3a0116d9cf71a8 Mon Sep 17 00:00:00 2001 From: jesperkha Date: Mon, 9 Mar 2026 12:25:03 +0100 Subject: [PATCH 1/6] cenv-go --- clients/cenv-go/README.md | 28 ++++ clients/cenv-go/cenv.go | 248 +++++++++++++++++++++++++++++++++++ clients/cenv-go/cenv_test.go | 142 ++++++++++++++++++++ clients/cenv-go/go.mod | 8 ++ clients/cenv-go/go.sum | 4 + clients/cenv-go/schema.go | 25 ++++ 6 files changed, 455 insertions(+) create mode 100644 clients/cenv-go/README.md create mode 100644 clients/cenv-go/cenv.go create mode 100644 clients/cenv-go/cenv_test.go create mode 100644 clients/cenv-go/go.mod create mode 100644 clients/cenv-go/go.sum create mode 100644 clients/cenv-go/schema.go diff --git a/clients/cenv-go/README.md b/clients/cenv-go/README.md new file mode 100644 index 0000000..fb3d088 --- /dev/null +++ b/clients/cenv-go/README.md @@ -0,0 +1,28 @@ +# cenv-go + +Go package for `cenv` runtime validation. + +## Install + +```sh +go get github.com/echo-webkom/cenv +``` + +## Use + +```go +package main + +import "github.com/echo-webkom/cenv" + +func main() { + config := cenv.Config{ + EnvPath: ".env", + SchemaPath: "cenv.schema.toml", + } + + if err := cenv.Check(config); err != nil { + log.Fatal(err) + } +} +``` diff --git a/clients/cenv-go/cenv.go b/clients/cenv-go/cenv.go new file mode 100644 index 0000000..3eb3aa8 --- /dev/null +++ b/clients/cenv-go/cenv.go @@ -0,0 +1,248 @@ +package cenv + +import ( + "errors" + "fmt" + "net" + "os" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/BurntSushi/toml" + "github.com/joho/godotenv" +) + +type Config struct { + EnvPath string + SchemaPath string +} + +var defaultConfig = Config{ + EnvPath: "./.env", + SchemaPath: "./cenv.schema.toml", +} + +// Check verifies that the environment variables match the cenv schema. [config] may be nil. +// +// Check tries to load a .env file using [config], or from the default path if not provided. +// If a .env file does not exist, the process env is used, and no error is returned. +// +// Check returns an error if the schema file is missing or malformed. +// +// // Default config +// Config{ +// EnvPath: "./.env", +// SchemaPath: "./cenv.schema.toml", +// } +func Check(config *Config) error { + if config == nil { + config = &defaultConfig + } + + _ = godotenv.Load(config.EnvPath) + + schema, err := readSchema(config.SchemaPath) + if err != nil { + return fmt.Errorf("cenv: failed to read schema: %v", err) + } + + if errs := validate(*schema); len(errs) > 0 { + var err error + for _, e := range errs { + err = errors.Join(err, e) + } + return err + } + + return nil +} + +func readSchema(path string) (*Schema, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var schema Schema + if err := toml.Unmarshal(data, &schema); err != nil { + return nil, err + } + + return &schema, nil +} + +type validationError struct { + Key string + Message string + Hint *string +} + +func (e validationError) Error() string { + if e.Hint != nil { + return fmt.Sprintf("%s: %s\n\thint: %s", e.Key, e.Message, *e.Hint) + } + return fmt.Sprintf("%s: %s", e.Key, e.Message) +} + +func validate(schema Schema) []validationError { + var errors []validationError + + for _, entry := range schema.Entries { + value, exists := os.LookupEnv(entry.Key) + hint := entry.Hint + + // Check required + if entry.Required { + if !exists { + errors = append(errors, validationError{ + Key: entry.Key, + Message: "required field is missing", + Hint: hint, + }) + continue + } + if value == "" { + errors = append(errors, validationError{ + Key: entry.Key, + Message: "required field is empty", + Hint: hint, + }) + continue + } + } + + // Skip further validation if value is missing or empty (and not required) + if !exists || value == "" { + continue + } + + // Check required_length + if entry.RequiredLength != nil { + requiredLen := *entry.RequiredLength + if len(value) != requiredLen { + errors = append(errors, validationError{ + Key: entry.Key, + Message: fmt.Sprintf("expected length %d, got %d", requiredLen, len(value)), + Hint: hint, + }) + } + } + + // Check legal_values + if len(entry.LegalValues) > 0 { + if !slices.Contains(entry.LegalValues, value) { + errors = append(errors, validationError{ + Key: entry.Key, + Message: fmt.Sprintf("value '%s' is not one of: %s", value, strings.Join(entry.LegalValues, ", ")), + Hint: hint, + }) + } + } + + // Check regex_match + if entry.RegexMatch != nil { + re, err := regexp.Compile(*entry.RegexMatch) + if err != nil { + errors = append(errors, validationError{ + Key: entry.Key, + Message: fmt.Sprintf("invalid regex pattern: %s", err), + Hint: hint, + }) + } else if !re.MatchString(value) { + errors = append(errors, validationError{ + Key: entry.Key, + Message: fmt.Sprintf("value does not match pattern: %s", *entry.RegexMatch), + Hint: hint, + }) + } + } + + // Check kind-specific validation + if entry.Kind != nil { + if errMsg := validateKind(value, entry.Kind); errMsg != "" { + errors = append(errors, validationError{ + Key: entry.Key, + Message: errMsg, + Hint: hint, + }) + } + } + } + + return errors +} + +func validateKind(value string, kind *EntryKind) string { + kindType := strings.ToLower(strings.ReplaceAll(kind.Type, "_", "")) + + switch kindType { + case "integer": + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Sprintf("'%s' is not a valid integer", value) + } + if kind.MinInt != nil && n < *kind.MinInt { + return fmt.Sprintf("value %d is less than minimum %d", n, *kind.MinInt) + } + if kind.MaxInt != nil && n > *kind.MaxInt { + return fmt.Sprintf("value %d is greater than maximum %d", n, *kind.MaxInt) + } + + case "float": + n, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Sprintf("'%s' is not a valid float", value) + } + if kind.MinFloat != nil && n < *kind.MinFloat { + return fmt.Sprintf("value %f is less than minimum %f", n, *kind.MinFloat) + } + if kind.MaxFloat != nil && n > *kind.MaxFloat { + return fmt.Sprintf("value %f is greater than maximum %f", n, *kind.MaxFloat) + } + + case "string": + // No validation needed for string type + return "" + + case "url": + // URL validation using RFC 3986 compliant regex + urlRegex := regexp.MustCompile(`(?i)^[a-z][a-z0-9+.-]*://(([a-z0-9._~%!$&'()*+,;=:-]*@)?([a-z0-9.-]+|\[[a-f0-9:]+\])(:[0-9]+)?)?(/[a-z0-9._~%!$&'()*+,;=:@/-]*)?(\?[a-z0-9._~%!$&'()*+,;=:@/?-]*)?(\#[a-z0-9._~%!$&'()*+,;=:@/?-]*)?$`) + if !urlRegex.MatchString(value) { + return fmt.Sprintf("'%s' is not a valid URL", value) + } + + case "email": + // Email validation using RFC 5321/5322 compliant regex + emailRegex := regexp.MustCompile(`(?i)^[a-z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) + if !emailRegex.MatchString(value) { + return fmt.Sprintf("'%s' is not a valid email address", value) + } + + case "bool": + lower := strings.ToLower(value) + validBools := []string{"true", "false", "1", "0", "yes", "no"} + if !slices.Contains(validBools, lower) { + return fmt.Sprintf("'%s' is not a valid boolean", value) + } + + case "ipaddress": + if net.ParseIP(value) == nil { + return fmt.Sprintf("'%s' is not a valid IP address", value) + } + + case "path": + if value == "" { + return "path cannot be empty" + } + // Path validation - validates Unix and Windows path formats + unixPathRegex := regexp.MustCompile(`^(/|\.{1,2}/)?([a-zA-Z0-9._-]+/?)*[a-zA-Z0-9._-]*$`) + windowsPathRegex := regexp.MustCompile(`(?i)^([a-z]:[/\\]|\\\\[a-z0-9._-]+[/\\][a-z0-9._-]+)?([a-zA-Z0-9._-]+[/\\]?)*[a-zA-Z0-9._-]*$`) + if !unixPathRegex.MatchString(value) && !windowsPathRegex.MatchString(value) { + return fmt.Sprintf("'%s' is not a valid path", value) + } + } + + return "" +} diff --git a/clients/cenv-go/cenv_test.go b/clients/cenv-go/cenv_test.go new file mode 100644 index 0000000..d68620c --- /dev/null +++ b/clients/cenv-go/cenv_test.go @@ -0,0 +1,142 @@ +package cenv + +import ( + "os" + "testing" +) + +func TestValidate_RequiredField(t *testing.T) { + schema := Schema{ + Entries: []Entry{{ + Key: "FOO", + Required: true, + }}, + } + os.Unsetenv("FOO") + errs := validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" || errs[0].Message != "required field is missing" { + t.Errorf("expected missing required field error, got: %+v", errs) + } + + os.Setenv("FOO", "") + errs = validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" || errs[0].Message != "required field is empty" { + t.Errorf("expected empty required field error, got: %+v", errs) + } + + os.Setenv("FOO", "bar") + errs = validate(schema) + if len(errs) != 0 { + t.Errorf("expected no error, got: %+v", errs) + } + os.Unsetenv("FOO") +} + +func TestValidate_RequiredLength(t *testing.T) { + schema := Schema{ + Entries: []Entry{{ + Key: "FOO", + RequiredLength: ptrInt(3), + }}, + } + os.Setenv("FOO", "ab") + errs := validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" { + t.Errorf("expected length error, got: %+v", errs) + } + os.Setenv("FOO", "abc") + errs = validate(schema) + if len(errs) != 0 { + t.Errorf("expected no error, got: %+v", errs) + } + os.Unsetenv("FOO") +} + +func TestValidate_LegalValues(t *testing.T) { + schema := Schema{ + Entries: []Entry{{ + Key: "FOO", + LegalValues: []string{"a", "b"}, + }}, + } + os.Setenv("FOO", "c") + errs := validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" { + t.Errorf("expected legal values error, got: %+v", errs) + } + os.Setenv("FOO", "a") + errs = validate(schema) + if len(errs) != 0 { + t.Errorf("expected no error, got: %+v", errs) + } + os.Unsetenv("FOO") +} + +func TestValidate_RegexMatch(t *testing.T) { + schema := Schema{ + Entries: []Entry{{ + Key: "FOO", + RegexMatch: ptrString(`^foo[0-9]+$`), + }}, + } + os.Setenv("FOO", "bar") + errs := validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" { + t.Errorf("expected regex error, got: %+v", errs) + } + os.Setenv("FOO", "foo123") + errs = validate(schema) + if len(errs) != 0 { + t.Errorf("expected no error, got: %+v", errs) + } + os.Unsetenv("FOO") +} + +func TestValidate_Kind_Integer(t *testing.T) { + schema := Schema{ + Entries: []Entry{{ + Key: "FOO", + Kind: &EntryKind{Type: "integer", MinInt: ptrInt64(10), MaxInt: ptrInt64(20)}, + }}, + } + os.Setenv("FOO", "5") + errs := validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" { + t.Errorf("expected min int error, got: %+v", errs) + } + os.Setenv("FOO", "25") + errs = validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" { + t.Errorf("expected max int error, got: %+v", errs) + } + os.Setenv("FOO", "15") + errs = validate(schema) + if len(errs) != 0 { + t.Errorf("expected no error, got: %+v", errs) + } + os.Unsetenv("FOO") +} + +func TestValidate_Kind_Bool(t *testing.T) { + schema := Schema{ + Entries: []Entry{{ + Key: "FOO", + Kind: &EntryKind{Type: "bool"}, + }}, + } + os.Setenv("FOO", "maybe") + errs := validate(schema) + if len(errs) != 1 || errs[0].Key != "FOO" { + t.Errorf("expected bool error, got: %+v", errs) + } + os.Setenv("FOO", "true") + errs = validate(schema) + if len(errs) != 0 { + t.Errorf("expected no error, got: %+v", errs) + } + os.Unsetenv("FOO") +} + +func ptrInt(i int) *int { return &i } +func ptrInt64(i int64) *int64 { return &i } +func ptrString(s string) *string { return &s } diff --git a/clients/cenv-go/go.mod b/clients/cenv-go/go.mod new file mode 100644 index 0000000..a0af0e9 --- /dev/null +++ b/clients/cenv-go/go.mod @@ -0,0 +1,8 @@ +module github.com/echo-webkom/cenv + +go 1.24.2 + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect +) diff --git a/clients/cenv-go/go.sum b/clients/cenv-go/go.sum new file mode 100644 index 0000000..8ba8cac --- /dev/null +++ b/clients/cenv-go/go.sum @@ -0,0 +1,4 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/clients/cenv-go/schema.go b/clients/cenv-go/schema.go new file mode 100644 index 0000000..d6612a5 --- /dev/null +++ b/clients/cenv-go/schema.go @@ -0,0 +1,25 @@ +package cenv + +type Schema struct { + Entries []Entry `toml:"entries"` +} + +type Entry struct { + Key string `toml:"key"` + Hint *string `toml:"hint,omitempty"` + Required bool `toml:"required"` + Default *string `toml:"default,omitempty"` + LegalValues []string `toml:"legal_values,omitempty"` + RequiredLength *int `toml:"required_length,omitempty"` + RegexMatch *string `toml:"regex_match,omitempty"` + Kind *EntryKind `toml:"kind,omitempty"` +} + +type EntryKind struct { + Type string `toml:"type"` + + MinInt *int64 `toml:"min_int,omitempty"` + MaxInt *int64 `toml:"max_int,omitempty"` + MinFloat *float64 `toml:"min_float,omitempty"` + MaxFloat *float64 `toml:"max_float,omitempty"` +} From 53fc93d8b7b6c24eb945ffc500044e03bbc11c16 Mon Sep 17 00:00:00 2001 From: jesperkha Date: Mon, 9 Mar 2026 12:26:06 +0100 Subject: [PATCH 2/6] mod tidy --- clients/cenv-go/go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/cenv-go/go.mod b/clients/cenv-go/go.mod index a0af0e9..79b1535 100644 --- a/clients/cenv-go/go.mod +++ b/clients/cenv-go/go.mod @@ -3,6 +3,6 @@ module github.com/echo-webkom/cenv go 1.24.2 require ( - github.com/BurntSushi/toml v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect + github.com/BurntSushi/toml v1.6.0 + github.com/joho/godotenv v1.5.1 ) From 17e576a1675640eb87361e5e8bb0e99c377fafca Mon Sep 17 00:00:00 2001 From: jesperkha Date: Mon, 9 Mar 2026 12:40:21 +0100 Subject: [PATCH 3/6] Fix go.mod --- clients/cenv-go/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/cenv-go/go.mod b/clients/cenv-go/go.mod index 79b1535..1210f62 100644 --- a/clients/cenv-go/go.mod +++ b/clients/cenv-go/go.mod @@ -1,4 +1,4 @@ -module github.com/echo-webkom/cenv +module github.com/echo-webkom/cenv/clients/cenv-go go 1.24.2 From 7108be9fae110884c6134d1fbd96654a2c256f89 Mon Sep 17 00:00:00 2001 From: jesperkha Date: Mon, 9 Mar 2026 12:44:50 +0100 Subject: [PATCH 4/6] Add go workflow --- .github/workflows/ci.yaml | 56 ++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c75e22..6888ab2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,37 +1,45 @@ name: ๐Ÿ’š CI on: - push: - branches: - - main - pull_request: - branches: - - main + push: + branches: + - main + pull_request: + branches: + - main jobs: - ci: - name: ๐Ÿ’š CI - runs-on: ubuntu-latest + ci: + name: ๐Ÿ’š CI + runs-on: ubuntu-latest - steps: - - name: ๐Ÿ” Checkout - uses: actions/checkout@v6 + steps: + - name: ๐Ÿ” Checkout + uses: actions/checkout@v6 - - name: ๐Ÿ“ฆ Setup Rust - uses: dtolnay/rust-toolchain@stable + - name: ๐Ÿ“ฆ Setup Rust + uses: dtolnay/rust-toolchain@stable - - name: ๐Ÿ“ Cache - uses: Swatinem/rust-cache@v2 + - name: ๐Ÿ“ Cache + uses: Swatinem/rust-cache@v2 - - name: ๐Ÿ”จ Build - run: cargo build + - name: ๐Ÿ”จ Build + run: cargo build - - name: ๐Ÿงช Test - run: cargo test + - name: ๐Ÿงช Test + run: cargo test - - name: ๐Ÿ“Ž Clippy - run: cargo clippy -- -D warnings + - name: ๐Ÿ“Ž Clippy + run: cargo clippy -- -D warnings - - name: ๐Ÿ“ Format check - run: cargo fmt -- --check + - name: ๐Ÿ“ Format check + run: cargo fmt -- --check + + - name: ๐Ÿน Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: ๐Ÿงช Go Test + run: go test ./clients/cenv-go/... From 65d23fbd5a29ac9a493897a3175e7f02747cb103 Mon Sep 17 00:00:00 2001 From: jesperkha Date: Mon, 9 Mar 2026 12:47:19 +0100 Subject: [PATCH 5/6] Fix ci --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6888ab2..1f21882 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,8 +38,8 @@ jobs: - name: ๐Ÿน Setup Go uses: actions/setup-go@v5 with: - go-version: "1.21" + go-version: "1.25" - name: ๐Ÿงช Go Test - run: go test ./clients/cenv-go/... + run: go test ./... From 9e32f59c8689349850cd934732be427313520138 Mon Sep 17 00:00:00 2001 From: jesperkha Date: Mon, 9 Mar 2026 12:49:50 +0100 Subject: [PATCH 6/6] Fix ci again --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1f21882..39c2d41 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,5 +41,5 @@ jobs: go-version: "1.25" - name: ๐Ÿงช Go Test - run: go test ./... + run: cd clients/cenv-go && go test ./...