Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ build-release:
go build -ldflags="-s -w" -o http-assert .

[doc("Run all pre-commit checks")]
pre-commit: build vet lint test test-race security
pre-commit: build tidy-check vet lint test test-race security

[doc("Build and check compilation without creating binary")]
check:
go build -o /dev/null .

[doc("Fail if go.mod or go.sum is not tidy")]
tidy-check:
go mod tidy -diff

[doc("Run go vet")]
vet:
go vet ./...
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,18 @@ http-assert --assert-ok https://api.example.com

**The remaining options are command-line only.** `--request`, `--header`, `--data` and every `--assert-*` flag ignore the environment; setting `HTTP_ASSERT_REQUEST=POST` has no effect.

**A command-line flag always wins over the environment**, which in turn wins over the built-in default.
**A command-line flag always wins over the environment**, which in turn wins over the built-in default. An empty variable counts as unset.

**A value that does not parse is rejected** rather than ignored, so a typo cannot silently change behaviour:

```console
$ HTTP_ASSERT_MAX_TIME=abc http-assert --assert-ok https://api.example.com
Error: Invalid value for HTTP_ASSERT_MAX_TIME="abc": strconv.ParseInt: parsing "abc": invalid syntax
$ echo $?
71
```

Booleans accept the Go forms (`true`, `false`, `1`, `0`, `t`, `f`, and their capitalisations); shell-style `yes`/`on` are rejected.

**`HTTP_ASSERT_MAPHOST` separates multiple mappings with whitespace, not commas:**

Expand Down
230 changes: 230 additions & 0 deletions e2e_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,233 @@ func TestE2EConfigEnvSliceSeparator(t *testing.T) {
assertExit(t, r, exitOK)
})
}

// The tests below pin value-level semantics that TestE2EConfigContract does not
// reach. That matrix asks "did this option take effect?" -- a boolean -- so it
// cannot see how a value was parsed, only that it was. Everything here exists
// because removing viper (#54) turns on exactly these paths.

// envSupportedCases returns the subset of the matrix whose options read the
// environment, so the value-level tests stay in step with it automatically.
func envSupportedCases(t *testing.T) []configCase {
t.Helper()

var out []configCase
for _, tc := range configCases(t) {
if tc.EnvSupported {
out = append(out, tc)
}
}
if len(out) != 6 {
t.Fatalf("expected 6 environment-backed options, got %d", len(out))
}
return out
}

// TestE2EConfigEnvEmptyMeansUnset pins that an empty variable is ignored rather
// than parsed. viper's AllowEmptyEnv defaults to false, so HTTP_ASSERT_MAX_TIME=""
// resolves to the flag default (20) and not to zero.
//
// The distinction is mostly invisible from outside -- every option's default
// happens to coincide with its zero value, and 0 vs 20 seconds cannot be told
// apart without a 20-second request. What this test does guarantee is that an
// empty variable never becomes an *error*, which is the regression that would
// otherwise slip through when unparseable values start being rejected.
func TestE2EConfigEnvEmptyMeansUnset(t *testing.T) {
for _, tc := range envSupportedCases(t) {
t.Run(tc.Flag, func(t *testing.T) {
r := run(t, map[string]string{tc.EnvKey: ""}, "--assert-ok", url("/ok"))
assertExit(t, r, exitOK)
assertNotContains(t, r, "Invalid value")
})
}
}

// TestE2EConfigEnvInvalidValues pins that an unparseable variable is rejected.
//
// It used to be cast to the type's zero value and applied silently. The
// MAX_TIME case is why that changed: "abc" became 0, and a zero
// http.Client.Timeout means no timeout at all, so a typo quietly removed the
// deadline from a tool whose entire job is enforcing one.
func TestE2EConfigEnvInvalidValues(t *testing.T) {
for _, tc := range []struct {
Name string
Env map[string]string
Args []string
Diag string
}{
{
Name: "max-time is not a number",
Env: map[string]string{"HTTP_ASSERT_MAX_TIME": "abc"},
Diag: `Invalid value for HTTP_ASSERT_MAX_TIME="abc"`,
},
{
Name: "max-time is a float",
Env: map[string]string{"HTTP_ASSERT_MAX_TIME": "1.5"},
Diag: `Invalid value for HTTP_ASSERT_MAX_TIME="1.5"`,
},
{
Name: "insecure is not a boolean",
Env: map[string]string{"HTTP_ASSERT_INSECURE": "garbage"},
Diag: `Invalid value for HTTP_ASSERT_INSECURE="garbage"`,
},
{
Name: "verbose is not a boolean",
Env: map[string]string{"HTTP_ASSERT_VERBOSE": "garbage"},
Diag: `Invalid value for HTTP_ASSERT_VERBOSE="garbage"`,
},
{
Name: "silent is not a boolean",
Env: map[string]string{"HTTP_ASSERT_SILENT": "maybe"},
Diag: `Invalid value for HTTP_ASSERT_SILENT="maybe"`,
},
} {
t.Run(tc.Name, func(t *testing.T) {
r := run(t, tc.Env, append(tc.Args, "--assert-ok", url("/ok"))...)
assertExit(t, r, exitBadFlagVal)
assertContains(t, r, tc.Diag)
// The reason is passed through rather than swallowed.
assertContains(t, r, "invalid syntax")
})
}

// log-level and maphost are string-valued, so the environment layer accepts
// them and their own parsers reject them. Same exit code, different message.
t.Run("log-level is validated by the level parser", func(t *testing.T) {
r := run(t, map[string]string{"HTTP_ASSERT_LOG_LEVEL": "trace"}, "--assert-ok", url("/ok"))
assertExit(t, r, exitBadFlagVal)
assertContains(t, r, `Invalid value for --log-level flag: "trace"`)
})

t.Run("maphost is validated by the mapping parser", func(t *testing.T) {
r := run(t, map[string]string{"HTTP_ASSERT_MAPHOST": "garbage"}, "--assert-ok", url("/ok"))
assertExit(t, r, exitBadFlagVal)
assertContains(t, r, "Invalid value for --maphost flag")
})
}

// TestE2EConfigEnvBooleanForms pins which spellings of true and false are
// understood. strconv.ParseBool and viper's cast agree on the canonical forms
// and disagree on "yes"/"on", which viper silently reads as false.
func TestE2EConfigEnvBooleanForms(t *testing.T) {
// Debug logging is observable, so verbose is the probe.
base := []string{"--maphost", "mapped.invalid:80=" + hostPort(),
"--assert-ok", "http://mapped.invalid/ok"}

t.Run("accepted", func(t *testing.T) {
for _, tc := range []struct {
Value string
On bool
}{
{"true", true}, {"1", true}, {"TRUE", true}, {"True", true}, {"t", true},
{"false", false}, {"0", false}, {"FALSE", false}, {"f", false},
} {
t.Run(tc.Value, func(t *testing.T) {
r := run(t, map[string]string{"HTTP_ASSERT_VERBOSE": tc.Value}, base...)
assertExit(t, r, exitOK)
if got := strings.Contains(r.Output(), "HostMappings"); got != tc.On {
t.Fatalf("HTTP_ASSERT_VERBOSE=%q: verbose=%v, want %v\n%s",
tc.Value, got, tc.On, r.Output())
}
})
}
})

// Shell-style spellings are not Go booleans. They used to be read as false,
// which meant `HTTP_ASSERT_VERBOSE=yes` quietly turned verbosity off.
t.Run("rejected", func(t *testing.T) {
for _, v := range []string{"yes", "on", "no", "off", "y", "n"} {
t.Run(v, func(t *testing.T) {
r := run(t, map[string]string{"HTTP_ASSERT_VERBOSE": v}, base...)
assertExit(t, r, exitBadFlagVal)
assertContains(t, r, "Invalid value for HTTP_ASSERT_VERBOSE")
})
}
})
}

// TestE2EConfigPrecedenceAllOptions extends the precedence guarantee from
// max-time to every environment-backed option. Each case sets the flag
// explicitly to the value the environment is trying to override, so a build
// that resolved the environment first would visibly lose.
func TestE2EConfigPrecedenceAllOptions(t *testing.T) {
mapping := "mapped.invalid:80=" + hostPort()
target := "http://mapped.invalid/ok"

for _, tc := range []struct {
Flag string
CLI []string
Env map[string]string
Base []string
Check func(t *testing.T, r result)
}{
{
Flag: "verbose", CLI: []string{"--verbose=false"},
Env: map[string]string{"HTTP_ASSERT_VERBOSE": "true"},
Base: []string{"--maphost", mapping, "--assert-ok", target},
Check: func(t *testing.T, r result) { assertNotContains(t, r, "HostMappings") },
},
{
Flag: "silent", CLI: []string{"--silent=false"},
Env: map[string]string{"HTTP_ASSERT_SILENT": "true"},
Base: []string{"--assert-ok", url("/ok")},
Check: func(t *testing.T, r result) { assertContains(t, r, "PASSED") },
},
{
Flag: "log-level", CLI: []string{"--log-level", "debug"},
Env: map[string]string{"HTTP_ASSERT_LOG_LEVEL": "error"},
Base: []string{"--maphost", mapping, "--assert-ok", target},
Check: func(t *testing.T, r result) { assertContains(t, r, "HostMappings") },
},
{
Flag: "insecure", CLI: []string{"--insecure=false"},
Env: map[string]string{"HTTP_ASSERT_INSECURE": "true"},
Base: []string{"--assert-ok", tlsSrv.URL},
Check: func(t *testing.T, r result) { assertContains(t, r, "certificate") },
},
{
Flag: "maphost", CLI: []string{"--maphost", "decoy.invalid:80=127.0.0.1:9"},
Env: map[string]string{"HTTP_ASSERT_MAPHOST": mapping},
Base: []string{"--assert-ok", target},
// The command-line mapping does not cover mapped.invalid, so the
// host must fail to resolve rather than reach the test server.
Check: func(t *testing.T, r result) { assertContains(t, r, "failed to send request") },
},
} {
t.Run(tc.Flag, func(t *testing.T) {
tc.Check(t, run(t, tc.Env, append(append([]string{}, tc.CLI...), tc.Base...)...))
})
}
}

// TestE2EConfigEnvLogLevels pins every level reachable through the environment.
// TestE2ELogLevels covers the same values on the command line; without this the
// environment path is only ever exercised at "error".
func TestE2EConfigEnvLogLevels(t *testing.T) {
mapping := "mapped.invalid:80=" + hostPort()
target := "http://mapped.invalid/ok"

for _, tc := range []struct {
Level string
Debug bool // the host-mapping summary is debug-only
Result bool // the PASSED line is info and above
}{
{"debug", true, true},
{"info", false, true},
{"warn", false, false},
{"error", false, false},
} {
t.Run(tc.Level, func(t *testing.T) {
r := run(t, map[string]string{"HTTP_ASSERT_LOG_LEVEL": tc.Level},
"--maphost", mapping, "--assert-ok", target)
assertExit(t, r, exitOK)

if got := strings.Contains(r.Output(), "HostMappings"); got != tc.Debug {
t.Fatalf("level %q: debug output=%v, want %v", tc.Level, got, tc.Debug)
}
if got := strings.Contains(r.Output(), "PASSED"); got != tc.Result {
t.Fatalf("level %q: result line=%v, want %v", tc.Level, got, tc.Result)
}
})
}
}
5 changes: 3 additions & 2 deletions e2e_known_issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,9 @@ func TestKnownIssue34StdoutAlwaysEmpty(t *testing.T) {
}

// TestKnownIssueWarnLevelIsDead: nothing logs at warn, so --log-level warn is
// byte-identical to --log-level error. The dead logWarn/logError helpers are
// silenced with //nolint:unused rather than removed.
// byte-identical to --log-level error. The unused logWarn/logError helpers have
// been removed; the LWarn level itself stays, because --log-level warn is part
// of the documented flag surface.
func TestKnownIssueWarnLevelIsDead(t *testing.T) {
characterizes(t, 0, "--log-level warn behaves exactly like error; nothing logs at warn")

Expand Down
6 changes: 6 additions & 0 deletions e2e_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -26,6 +28,10 @@ func TestMain(m *testing.M) {
func(w http.ResponseWriter, _ *http.Request) {
write(w, http.StatusOK, []byte("secure-ok"), nil)
}))
// Several tests deliberately connect without --insecure, so the server logs
// a rejected handshake every time. That is the expected result, not a
// failure; keep it out of the test output.
tlsSrv.Config.ErrorLog = log.New(io.Discard, "", 0)

code := m.Run()

Expand Down
14 changes: 3 additions & 11 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,15 @@ go 1.25.5
require (
github.com/onsi/gomega v1.39.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/spf13/pflag v1.0.10
)

require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)
30 changes: 2 additions & 28 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
Expand All @@ -27,32 +20,15 @@ github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc
github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
Expand All @@ -70,5 +46,3 @@ golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading
Loading