package config import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "reflect" "strings" "sync" "testing" "time" ) type loaderExample struct { Server Config `json:"server"` Upload struct { MaxBytes int64 `json:"maxBytes"` } `json:"upload"` Values []int64 `json:"values"` Optional *string `json:"optional"` Enabled bool `json:"enabled"` Dynamic any `json:"dynamic"` } func (c loaderExample) Validate() error { if err := c.Server.Validate(); err != nil { return AtField("server", err) } if c.Upload.MaxBytes < 0 { return Invalid("upload.maxBytes", "must be nonnegative") } return nil } func noEnvironment(string) (string, bool) { return "", false } func configFile(t *testing.T, ext, raw string) string { t.Helper() path := filepath.Join(t.TempDir(), "service."+ext) if err := os.WriteFile(path, []byte(raw), 0600); err != nil { t.Fatal(err) } return path } func TestUnifiedFormatAndPrecedence(t *testing.T) { formats := map[string]string{ "json": `{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":"${APP_UPLOAD_MAX_BYTES}"},"values":[9007199254740993],"optional":null,"enabled":"${APP_ENABLED}","dynamic":{"id":9007199254740993}}`, "yaml": "server:\n http:\n readTimeout: 2s\nupload:\n maxBytes: \"${APP_UPLOAD_MAX_BYTES}\"\nvalues: [9007199254740993]\noptional: null\nenabled: \"${APP_ENABLED}\"\ndynamic:\n id: 9007199254740993\n", "xml": `2s${APP_UPLOAD_MAX_BYTES}9007199254740993${APP_ENABLED}9007199254740993`, } var reference loaderExample for _, ext := range []string{"json", "yaml", "xml"} { t.Run(ext, func(t *testing.T) { old := "old" base := loaderExample{Server: Default(), Optional: &old} cfg, err := LoadProject(configFile(t, ext, formats[ext]), base, testLoader(func(key string) (string, bool) { v, ok := map[string]string{"APP_UPLOAD_MAX_BYTES": "0", "APP_ENABLED": "false"}[key] return v, ok })) if err != nil { t.Fatal(err) } if cfg.Upload.MaxBytes != 0 || cfg.Enabled || cfg.Optional != nil || cfg.Values[0] != 9007199254740993 || cfg.Server.HTTP.ReadTimeout != 2*time.Second { t.Fatal("values changed", cfg) } if cfg.Dynamic.(map[string]any)["id"] != json.Number("9007199254740993") { t.Fatal("dynamic integer precision lost") } if ext == "json" { reference = cfg } else if !reflect.DeepEqual(reference, cfg) { t.Fatal("format-dependent semantics") } if base.Optional == nil || *base.Optional != "old" { t.Fatal("defaults mutated") } }) } cfg, err := LoadProject("", loaderExample{Server: Default()}, testLoader(noEnvironment)) if err != nil || cfg.Upload.MaxBytes != 0 { t.Fatal(cfg, err) } } func TestConfigDiagnosticsAndCauses(t *testing.T) { for _, tc := range []struct { ext, body, field string line bool }{ {"json", "{\n\"upload\": {\"maxBytes\":\"private-value\"}}", "upload.maxBytes", true}, {"yaml", "server:\n http:\n readTimeout: private-value", "server.http.readTimeout", true}, {"xml", "\nprivate-value", "upload.maxBytes", true}, {"json", `{"upload":{"maxBytes":-1}}`, "upload.maxBytes", true}, {"yaml", "server:\n http:\n readTimeout: -1s", "server.http.readTimeout", true}, {"json", `{"upload":{"typo":3}}`, "upload.typo", true}, } { t.Run(tc.ext+tc.field, func(t *testing.T) { path := configFile(t, tc.ext, tc.body) _, err := LoadProject(path, loaderExample{Server: Default()}, testLoader(noEnvironment)) var d *Error if !errors.As(err, &d) || d.File != path || d.Field != tc.field || d.Reason == "" || tc.line && d.Line < 1 { t.Fatalf("missing location: %#v %v", d, err) } if strings.Contains(err.Error(), "private-value") { t.Fatal("secret leaked", err) } }) } path := filepath.Join(t.TempDir(), "missing.json") _, err := LoadProject(path, loaderExample{}) if !errors.Is(err, os.ErrNotExist) { t.Fatal("I/O cause discarded", err) } _, err = DecodeFile(configFile(t, "json", `{"count":"private-value"}`), struct { Count int `json:"count"` }{}) var typed *json.UnmarshalTypeError if !errors.As(err, &typed) { t.Fatal("type cause discarded", err) } _, err = LoadProject(configFile(t, "yaml", "upload:\n maxBytes: \"${APP_UPLOAD_MAX_BYTES}\""), loaderExample{Server: Default()}, testLoader(func(k string) (string, bool) { return "-1", k == "APP_UPLOAD_MAX_BYTES" })) var d *Error if !errors.As(err, &d) || d.Env != "APP_UPLOAD_MAX_BYTES" || d.Field != "upload.maxBytes" || d.File == "" || d.Line != 2 { t.Fatal("missing file and variable provenance", err) } } func TestMalformedFormatsAndLimits(t *testing.T) { for ext, bodies := range map[string][]string{ "json": {`{"enabled":true,"enabled":false}`, `{} {}`, `{"upload":`, `{"enabled":true,"Enabled":false}`, strings.Repeat(`{"a":`, 70) + `0` + strings.Repeat(`}`, 70)}, "yaml": {"enabled: true\nenabled: false", "---\n{}\n---\n{}", "a: &x [1]\nb: *x", "a:\n <<: {}", "upload: [\n"}, "xml": {``, `&private-value;`, ``, ``, ``, `truefalse`, `texttrue`, `1`}, } { for i, raw := range bodies { t.Run(fmt.Sprintf("%s%d", ext, i), func(t *testing.T) { _, err := LoadProject(configFile(t, ext, raw), loaderExample{Server: Default()}, testLoader(noEnvironment)) if err == nil || strings.Contains(err.Error(), "private-value") { t.Fatal(err) } }) } } if _, err := DecodeFile(configFile(t, "json", strings.Repeat(" ", maxConfigBytes+1)), loaderExample{}); err == nil { t.Fatal("unbounded file") } if _, err := DecodeFile(configFile(t, "json", string([]byte{'{', '"', 0xff, '"', ':', '0', '}'})), loaderExample{}); err == nil { t.Fatal("invalid UTF-8") } } func TestDecoderRegistryAndConcurrentUse(t *testing.T) { decoder := DecoderFunc(func([]byte) (Document, error) { return Document{Value: map[string]any{"upload": map[string]any{"maxBytes": json.Number("42")}}}, nil }) l, err := NewLoader(WithDecoder("custom", decoder)) if err != nil { t.Fatal(err) } path := configFile(t, "custom", "ignored") var wg sync.WaitGroup for i := 0; i < 24; i++ { wg.Add(1) go func() { defer wg.Done() cfg, e := LoadProject(path, loaderExample{Server: Default()}, l) if e != nil || cfg.Upload.MaxBytes != 42 { t.Error(e) } }() } wg.Wait() if _, err := DecodeFile(path, loaderExample{}); err == nil { t.Fatal("decoder registration leaked globally") } for _, options := range [][]LoaderOption{{WithDecoder("json", decoder)}, {WithDecoder("x", decoder), WithDecoder("x", decoder)}, {WithDecoder("x", DecoderFunc(nil))}} { if _, err = NewLoader(options...); err == nil { t.Fatal("invalid registration") } } l, _ = NewLoader(WithDecoder("panic", DecoderFunc(func([]byte) (Document, error) { panic("private-value") }))) _, err = DecodeFile(configFile(t, "panic", ""), loaderExample{}, l) if err == nil || strings.Contains(err.Error(), "private-value") { t.Fatal(err) } cycle := map[string]any{} cycle["self"] = cycle l, _ = NewLoader(WithDecoder("cycle", DecoderFunc(func([]byte) (Document, error) { return Document{Value: map[string]any{"dynamic": cycle}}, nil }))) if _, err = DecodeFile(configFile(t, "cycle", ""), loaderExample{}, l); err == nil { t.Fatal("decoder bypassed traversal limit") } } func FuzzConfigurationDecoders(f *testing.F) { f.Add(uint8(0), []byte(`{"count":1}`)) f.Add(uint8(1), []byte("count: 1")) f.Add(uint8(2), []byte("1")) f.Fuzz(func(t *testing.T, format uint8, data []byte) { if len(data) > 8192 { data = data[:8192] } ds := []Decoder{JSONDecoder{}, YAMLDecoder{}, XMLDecoder{}} doc, err := ds[int(format)%len(ds)].Decode(data) if err == nil { _, _ = bindDocument(doc, struct { Count int `json:"count"` }{}) } }) } func BenchmarkConfiguration(b *testing.B) { for name, tc := range map[string]struct { d Decoder raw string }{"json": {JSONDecoder{}, `{"count":9007199254740993,"timeout":"5s","values":[1,2,3]}`}, "yaml": {YAMLDecoder{}, "count: 9007199254740993\ntimeout: 5s\nvalues: [1,2,3]"}, "xml": {XMLDecoder{}, `90071992547409935s123`}} { b.Run(name, func(b *testing.B) { b.ReportAllocs() raw := []byte(tc.raw) for i := 0; i < b.N; i++ { doc, err := tc.d.Decode(raw) if err != nil { b.Fatal(err) } _, err = bindDocument(doc, struct { Count int64 `json:"count"` Timeout time.Duration `json:"timeout"` Values []int `json:"values"` }{}) if err != nil { b.Fatal(err) } } }) } } // Text-only scalars and embedded config fields use the same behavior in each format. type testText struct{ Value string } func (v *testText) UnmarshalText(data []byte) error { v.Value = string(data); return nil } func TestConfigScalarAndEmbedding(t *testing.T) { type Fields struct { Name string `json:"name"` } type C struct { Fields Nested Fields `json:"nested"` Scalar testText `json:"scalar"` Bytes []byte `json:"bytes"` } cases := map[string]string{ "json": `{"name":"outer","nested":{"name":"inner"},"scalar":"text","bytes":"aGk="}`, "yaml": "name: outer\nnested:\n name: inner\nscalar: text\nbytes: aGk=\n", "xml": `outerinnertextaGk=`, } for ext, data := range cases { t.Run(ext, func(t *testing.T) { path := filepath.Join(t.TempDir(), "config."+ext) os.WriteFile(path, []byte(data), 0600) got, err := DecodeFile(path, &C{}) if err != nil { t.Fatal(err) } if got.Name != "outer" || got.Nested.Name != "inner" || got.Scalar.Value != "text" || string(got.Bytes) != "hi" { t.Fatal(got) } }) } type Tagged struct { Fields `json:"nested"` } doc, _ := (JSONDecoder{}).Decode([]byte(`{"name":"wrong"}`)) if _, err := bindDocument(doc, Tagged{}); err == nil { t.Fatal("tagged embed incorrectly flattened") } } func TestConfigDefaultsIsolationAndEmptyValues(t *testing.T) { type Child struct { Name string `json:"name"` } type C struct { Child *Child `json:"child"` Items []int `json:"items"` Labels map[string]string `json:"labels"` Enabled bool `json:"enabled"` } base := C{Child: &Child{Name: "old"}, Items: []int{1}, Labels: map[string]string{"old": "yes"}, Enabled: true} doc, _ := (JSONDecoder{}).Decode([]byte(`{"child":{"name":"new"},"labels":{"new":"yes"},"items":[],"enabled":false}`)) got, err := bindDocument(doc, base) if err != nil { t.Fatal(err) } if got.Child.Name != "new" || got.Items == nil || len(got.Items) != 0 || got.Enabled { t.Fatal(got) } if base.Child.Name != "old" || len(base.Items) != 1 || len(base.Labels) != 1 { t.Fatal("mutated defaults") } bad, _ := (JSONDecoder{}).Decode([]byte(`{"child":{"name":"changed"},"items":"bad"}`)) if _, err := bindDocument(bad, base); err == nil { t.Fatal("invalid input accepted") } if base.Child.Name != "old" { t.Fatal("failed load changed default pointer") } null, _ := (JSONDecoder{}).Decode([]byte(`{"child":null,"items":null,"labels":null,"enabled":null}`)) got, err = bindDocument(null, base) if err != nil || got.Child != nil || got.Items != nil || got.Labels != nil || !got.Enabled { t.Fatal(got, err) } } func TestDefaultStageDoesNotReadEnvironment(t *testing.T) { reads := 0 type C struct { Value int `json:"value"` } got, err := LoadProject("", C{Value: 3}, testLoader(func(string) (string, bool) { reads++; return "9", true })) if err != nil || got.Value != 3 || reads != 0 { t.Fatal(got, err, reads) } } func testLoader(lookup func(string) (string, bool)) *Loader { l, err := NewLoader(WithLookupEnv(lookup)) if err != nil { panic(err) } return l } func TestDecoderRegistryFrozen(t *testing.T) { l, err := NewLoader() if err != nil { t.Fatal(err) } if err = WithDecoder("extra", JSONDecoder{})(l); err == nil { t.Fatal("mutable registry") } } func BenchmarkConfigurationStartup(b *testing.B) { path := filepath.Join(b.TempDir(), "app.json") if err := os.WriteFile(path, []byte(`{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":4096}}`), 0600); err != nil { b.Fatal(err) } defaults := loaderExample{Server: Default()} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { if _, err := LoadProject(path, defaults); err != nil { b.Fatal(err) } } } func BenchmarkConfigurationLarge(b *testing.B) { type Entry struct { Timeout time.Duration `json:"timeout"` Count int `json:"count"` } type Large struct { Entries []Entry `json:"entries"` } raw := []byte(`{"entries":[` + strings.TrimSuffix(strings.Repeat(`{"timeout":"3s","count":10},`, 1000), ",") + `]}`) b.SetBytes(int64(len(raw))) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { doc, err := (JSONDecoder{}).Decode(raw) if err != nil { b.Fatal(err) } if _, err = bindDocument(doc, Large{}); err != nil { b.Fatal(err) } } }