From c37c7b3ba20517d965b84c945555cb98065aec61 Mon Sep 17 00:00:00 2001 From: Dylan Myers Date: Tue, 4 Aug 2026 13:37:46 -0400 Subject: [PATCH] feat(config): validate network CIDRs at environment load (PIPE-1002) Assisted-by: Claude Opus 4.8 --- internal/config/environment.go | 34 +++++++++++++- internal/config/environment_test.go | 71 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/internal/config/environment.go b/internal/config/environment.go index 70e7afa..1322581 100644 --- a/internal/config/environment.go +++ b/internal/config/environment.go @@ -120,5 +120,37 @@ func (e EnvironmentConfig) Build(logger *zap.Logger) (*datagen.Environment, erro DomainAdminsCount: e.Counts.DomainAdmins, Logger: logger, } - return datagen.GenerateEnvironment(seeds, opts) + + env, err := genEnvironment(seeds, opts) + if err != nil { + return nil, err + } + + // Gate the resolved environment on the network CIDR contract: an invalid + // CIDR fails the whole load rather than silently defaulting (PIPE-1002). + // Generated networks are always valid today; this is the enforcement point + // for when user-supplied CIDRs flow through. + if err := validateNetworks(env.Networks); err != nil { + return nil, fmt.Errorf("environment: %w", err) + } + return env, nil +} + +// genEnvironment is the environment-composition seam. Build calls through it so +// tests can inject an environment (e.g. one carrying an invalid network CIDR) +// and assert Build's validation behavior; production always uses the real +// datagen.GenerateEnvironment. +var genEnvironment = datagen.GenerateEnvironment + +// validateNetworks rejects any network whose CIDR fails datagen's blitz-network +// contract (see datagen.ValidateCIDR): unparseable, non-IPv4, or a prefix +// longer than /29. The first failure fails the entire environment load — the +// contract is "refuse to start", not "fall back to a default". +func validateNetworks(networks []*datagen.NetworkIdentity) error { + for _, n := range networks { + if err := n.Validate(); err != nil { + return err + } + } + return nil } diff --git a/internal/config/environment_test.go b/internal/config/environment_test.go index b3551c7..958a6c7 100644 --- a/internal/config/environment_test.go +++ b/internal/config/environment_test.go @@ -1,13 +1,84 @@ package config import ( + "errors" + "strings" "testing" + "github.com/observiq/blitz/internal/datagen" "go.uber.org/zap" ) func i64(v int64) *int64 { return &v } +// TestValidateNetworks covers the config-load CIDR gate directly: valid IPv4 +// networks pass, while a network whose CIDR is unparseable, non-IPv4, or has a +// prefix longer than /29 fails and the error names the offending CIDR. +func TestValidateNetworks(t *testing.T) { + valid := []*datagen.NetworkIdentity{ + {ID: "n1", CIDR: "10.10.1.0/24"}, + {ID: "n2", CIDR: "192.168.0.0/29"}, + {ID: "n3", CIDR: "10.0.0.0/8"}, + } + if err := validateNetworks(valid); err != nil { + t.Errorf("validateNetworks(valid) = %v, want nil", err) + } + + for _, bad := range []string{"10.10.1.0/30", "10.10.1.0/31", "10.10.1.0/32", "not-a-cidr", "2001:db8::/64"} { + nets := []*datagen.NetworkIdentity{ + {ID: "ok", CIDR: "10.10.1.0/24"}, + {ID: "bad", CIDR: bad}, + } + err := validateNetworks(nets) + if err == nil { + t.Errorf("validateNetworks with CIDR %q = nil, want error", bad) + continue + } + if !strings.Contains(err.Error(), bad) { + t.Errorf("error for CIDR %q = %q, want it to name the offending CIDR", bad, err.Error()) + } + } +} + +// TestEnvironmentConfig_Build_RejectsInvalidNetworkCIDR confirms the gate is +// wired into the config-load path: when the environment resolves a network with +// an invalid CIDR, Build fails (rather than silently defaulting) and surfaces +// the offending CIDR. The genEnvironment seam injects the invalid network, +// since normal generation only ever produces valid CIDRs. +func TestEnvironmentConfig_Build_RejectsInvalidNetworkCIDR(t *testing.T) { + orig := genEnvironment + defer func() { genEnvironment = orig }() + genEnvironment = func(_ *datagen.SeedConfig, _ *datagen.EnvironmentOpts) (*datagen.Environment, error) { + return &datagen.Environment{ + Networks: []*datagen.NetworkIdentity{{ID: "bad", Name: "Bad", CIDR: "10.10.1.0/30"}}, + }, nil + } + + _, err := EnvironmentConfig{}.Build(zap.NewNop()) + if err == nil { + t.Fatal("Build with an invalid network CIDR should fail, got nil") + } + if !strings.Contains(err.Error(), "10.10.1.0/30") { + t.Errorf("Build error = %q, want it to name the offending CIDR", err.Error()) + } +} + +// TestEnvironmentConfig_Build_PropagatesGenerateError confirms Build forwards an +// environment-composition error unchanged (before it reaches the network gate). +func TestEnvironmentConfig_Build_PropagatesGenerateError(t *testing.T) { + orig := genEnvironment + defer func() { genEnvironment = orig }() + sentinel := errors.New("boom") + genEnvironment = func(_ *datagen.SeedConfig, _ *datagen.EnvironmentOpts) (*datagen.Environment, error) { + return nil, sentinel + } + + _, err := EnvironmentConfig{}.Build(zap.NewNop()) + if !errors.Is(err, sentinel) { + t.Errorf("Build error = %v, want the sentinel generation error", err) + } +} + func TestEnvironmentConfig_Build_Counts(t *testing.T) { cfg := EnvironmentConfig{ SeedConfig: EnvironmentSeedConfig{Shared: i64(42)},