Fix the bbolt round trip, the blocklist gaps and the deploy set
The cache snapshot never loaded, so every restart was a cold cache. Snapshot
packed the whole key while Load decoded the record header out of the key's
remainder instead of the value, so every entry was skipped. The two sides are
now an explicit encodeRecord/decodeRecord pair with a version byte and a CRC-32C
trailer, so a future format change is detected instead of silently misparsed.
Load also drops entries that are already past ExpiresAt and rewrites the TTL to
the time actually left, so a served TTL never jumps back up across a restart.
Blocklist, all six gaps the failing tests were pointing at: a leading UTF-8 BOM
is stripped, "!allow example.com" works, SourceMeta exists for a source that
only ever contributed a rule, Pattern() reports the bare name again (the new
WireName() produces the wildcard form for the API), an RPZ record with no
explicit TTL inherits the zone default instead of whatever the reader left in
the RR, a hosts line with no address in the first field says so, and the
adblock parser handles CRLF and adblock-flavoured lines pasted into a plain
list. TestHolderSwapIsAtomic was asserting the wrong invariant — the swap was
always atomic, a name blocked by the old set is legitimately unblocked by the
new one.
Tests added for acl and resolver, both of which turned up real bugs:
- a PTR suffix matched across label boundaries, so a group scoped to
example.com. also swallowed ample.com. and notexample.com. — a client could
land in a group it does not belong to;
- the "*" catch-all was dropped from the rendered domain patterns, so the
engine's per-query recompile lost the "everything" case;
- Upstream.Success never closed an open breaker, so a recovered upstream stayed
excluded for the full cooldown;
- Exchange fanned out in parallel even under policy: failover.
Deploy set: go.mod requires Go 1.27.1 but CI pinned 1.23 and the Dockerfile
pinned golang:1.23-alpine, so neither could have built. The Dockerfile also
copied the source tree after the console bundle, which clobbered it.
The k8s set asked for hostNetwork with two replicas, but the server never sets
SO_REUSEPORT, so the second pod could not bind :53 and would crash-loop. It now
runs without hostNetwork behind a LoadBalancer Service, with a separate
ClusterIP Service for the Ingress (a headless Service cannot back one), a
ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA
is gone: it cannot drive a StatefulSet. To let the API key come from that
Secret, config.Load now expands ${VAR} / ${VAR:-default} / ${NAME-default} and
fails to start on an unset variable with no default, so a missing secret is a
crash loop rather than an unauthenticated console. Expansion skips YAML
comments, so documenting ${VAR} in the ConfigMap does not break the file.
Verified: gofmt, go build, go vet clean; go test -race ./internal/... green
(acl, app, blocklist, cache, config, resolver, zonefile).
This commit is contained in:
1 parent
3bcc10a029
commit
26134ba437
22 files changed
+3106
-225
No files matched your search
+143
-6
@@ -8,13 +8,16 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -39,14 +42,23 @@ func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
||||
*d = Duration(n)
|
||||
return nil
|
||||
}
|
||||
// time.ParseDuration insists on a unit, so the numeric seconds forms
|
||||
// (7, 1.5) have to be handled here; without this the documented "a
|
||||
// bare integer is seconds" rule never worked, because yaml decodes
|
||||
// every scalar into a string and the old float fallback was
|
||||
// unreachable.
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
ns := f * float64(time.Second)
|
||||
if ns >= float64(math.MaxInt64) || ns < float64(math.MinInt64) {
|
||||
return fmt.Errorf("invalid duration %q: out of range", s)
|
||||
}
|
||||
*d = Duration(time.Duration(ns))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
var f float64
|
||||
if err := value.Decode(&f); err != nil {
|
||||
return fmt.Errorf("invalid duration: %s", value.Value)
|
||||
}
|
||||
*d = Duration(time.Duration(f * float64(time.Second)))
|
||||
return nil
|
||||
// Not a scalar at all (mapping/sequence): nothing sensible to interpret.
|
||||
return fmt.Errorf("invalid duration: %s", value.Value)
|
||||
}
|
||||
|
||||
func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil }
|
||||
@@ -560,6 +572,10 @@ func Load(path string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
raw, err = expandEnv(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config %s: %w", path, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
@@ -574,6 +590,127 @@ func Load(path string) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// expandEnv substitutes ${NAME} and ${NAME:-default} in a config document
|
||||
// before it is parsed, so a secret can come from the environment (a
|
||||
// Secret-mounted env var in Kubernetes, for instance) instead of being
|
||||
// committed next to the rest of the config.
|
||||
//
|
||||
// Three rules, each of which exists because breaking it is a real footgun:
|
||||
//
|
||||
// - Only the braced forms are touched, so a bare `$` in a URL or a
|
||||
// blocklist rule survives verbatim.
|
||||
// - Comments are copied through untouched. A config that *documents* the
|
||||
// syntax ("# api_keys: ${FANCYDNS_API_KEY}") must still load.
|
||||
// - An unset variable with no default is an error, not an empty string:
|
||||
// `api_keys: ["${FANCYDNS_API_KEY}"]` silently expanding to `[""]` is how
|
||||
// a deployment ends up with no authentication at all.
|
||||
func expandEnv(raw []byte) ([]byte, error) {
|
||||
if !bytes.Contains(raw, []byte("${")) {
|
||||
return raw, nil
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.Grow(len(raw))
|
||||
// Line-at-a-time so the comment state can be tracked: a `#` only opens a
|
||||
// comment outside quotes, and only when it starts the line or follows
|
||||
// whitespace. That is YAML's rule, and a config with `#` inside a quoted
|
||||
// string must not be truncated at it.
|
||||
for off := 0; off < len(raw); {
|
||||
line := raw[off:]
|
||||
if nl := bytes.IndexByte(line, '\n'); nl >= 0 {
|
||||
line = line[:nl+1]
|
||||
}
|
||||
err := expandLine(line, &b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: %w", bytes.Count(raw[:off], []byte("\n"))+1, err)
|
||||
}
|
||||
off += len(line)
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// expandLine appends one line to b, substituting the ${...} references it
|
||||
// finds outside comments.
|
||||
//
|
||||
// The scan tracks quotes so that two rules hold at once: a `#` only opens a
|
||||
// comment outside quotes and only at the start of a line or after whitespace
|
||||
// (YAML's rule, so a `#` inside a quoted string is data, not a comment), and a
|
||||
// reference inside a quoted scalar — which is how `api_keys: ["${KEY}"]` is
|
||||
// naturally written — is expanded rather than copied through.
|
||||
func expandLine(line []byte, b *bytes.Buffer) error {
|
||||
var inSingle, inDouble, escaped, inComment bool
|
||||
for i := 0; i < len(line); {
|
||||
c := line[i]
|
||||
if inComment {
|
||||
b.WriteByte(c)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case inDouble:
|
||||
switch {
|
||||
case escaped:
|
||||
escaped = false
|
||||
case c == '\\':
|
||||
escaped = true
|
||||
case c == '"':
|
||||
inDouble = false
|
||||
}
|
||||
case inSingle:
|
||||
if c == '\'' {
|
||||
inSingle = false
|
||||
}
|
||||
case c == '#' && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t'):
|
||||
inComment = true
|
||||
case c == '"':
|
||||
inDouble = true
|
||||
case c == '\'':
|
||||
inSingle = true
|
||||
case c == '$' && i+1 < len(line) && line[i+1] == '{':
|
||||
end := bytes.IndexByte(line[i+2:], '}')
|
||||
if end < 0 {
|
||||
return fmt.Errorf("unterminated ${ at byte %d", i)
|
||||
}
|
||||
spec := string(line[i+2 : i+2+end])
|
||||
i += end + 3
|
||||
|
||||
name, def := spec, ""
|
||||
hasDefault := false
|
||||
// colonDefault marks the ":-" form, which substitutes for an empty
|
||||
// value as well as an unset one (POSIX). A bare "-" default applies
|
||||
// only when the variable is unset.
|
||||
colonDefault := false
|
||||
switch k := strings.Index(spec, ":-"); {
|
||||
case k >= 0:
|
||||
name, def, hasDefault, colonDefault = spec[:k], spec[k+2:], true, true
|
||||
case strings.IndexByte(spec, '-') >= 0:
|
||||
// A bare `-` is a default too, for an unset (not an empty)
|
||||
// variable. It splits on the FIRST hyphen, so a variable name
|
||||
// may not contain one.
|
||||
k = strings.IndexByte(spec, '-')
|
||||
name, def, hasDefault = spec[:k], spec[k+1:], true
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("empty variable name in ${%s}", spec)
|
||||
}
|
||||
v, isSet := os.LookupEnv(name)
|
||||
switch {
|
||||
case isSet && (v != "" || hasDefault && !colonDefault):
|
||||
// v is "" here only for the "-" form with an empty variable,
|
||||
// which is the whole difference between ${X-y} and ${X:-y}.
|
||||
b.WriteString(v)
|
||||
case hasDefault:
|
||||
b.WriteString(def)
|
||||
default:
|
||||
return fmt.Errorf("environment variable %s is not set (use ${%s:-default} to give it a fallback)", name, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorkingDirOrSet records the config directory so relative paths in the
|
||||
// document resolve consistently regardless of the process cwd.
|
||||
func (c *Config) WorkingDirOrSet(dir string) {
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestDurationUnmarshalYAMLAccepted(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node string
|
||||
want time.Duration
|
||||
}{
|
||||
{"seconds", `5s`, 5 * time.Second},
|
||||
{"compound minutes seconds", `1m30s`, 90 * time.Second},
|
||||
{"milliseconds", `250ms`, 250 * time.Millisecond},
|
||||
{"hours minutes", `1h30m`, 90 * time.Minute},
|
||||
{"sub second", `1.5s`, 1500 * time.Millisecond},
|
||||
{"microseconds", `10us`, 10 * time.Microsecond},
|
||||
{"zero", `0s`, 0},
|
||||
{"negative", `-5s`, -5 * time.Second},
|
||||
{"padded", `" 30s "`, 30 * time.Second},
|
||||
{"empty string is zero", `""`, 0},
|
||||
{"null is zero", `null`, 0},
|
||||
{"tilde is zero", `~`, 0},
|
||||
{"int seconds", `7`, 7 * time.Second},
|
||||
{"float seconds", `1.5`, 1500 * time.Millisecond},
|
||||
{"negative int seconds", `-7`, -7 * time.Second},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got struct {
|
||||
D Duration `yaml:"d"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte("d: "+tc.node), &got); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", tc.node, err)
|
||||
}
|
||||
if got.D.D() != tc.want {
|
||||
t.Errorf("d: %s = %v, want %v", tc.node, got.D.D(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationUnmarshalYAMLRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node string
|
||||
mention string
|
||||
}{
|
||||
{"nonsense", `abc`, `"abc"`},
|
||||
{"nonsense", `abc`, `"abc"`},
|
||||
{"space before unit", `"5 s"`, `"5 s"`},
|
||||
{"unknown unit", `10x`, `"10x"`},
|
||||
{"overflow", `2562048h`, `"2562048h"`},
|
||||
{"float overflow", `1e400s`, `1e400s`},
|
||||
{"numeric seconds overflow", `1e30`, `"1e30"`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got struct {
|
||||
D Duration `yaml:"d"`
|
||||
}
|
||||
err := yaml.Unmarshal([]byte("d: "+tc.node), &got)
|
||||
if err == nil {
|
||||
t.Fatalf("unmarshal %s: expected error, got %v", tc.node, got.D.D())
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.mention) {
|
||||
t.Errorf("error %q does not mention offending value %s", err, tc.mention)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationUnmarshalYAMLNonScalar(t *testing.T) {
|
||||
for _, node := range []string{`{a: 1}`, `[1,2]`} {
|
||||
var got struct {
|
||||
D Duration `yaml:"d"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte("d: "+node), &got); err == nil {
|
||||
t.Errorf("unmarshal d: %s: expected error, got %v", node, got.D.D())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationMarshalYAMLRoundTrip(t *testing.T) {
|
||||
for _, d := range []Duration{
|
||||
Duration(5 * time.Second),
|
||||
Duration(90 * time.Second),
|
||||
Duration(250 * time.Millisecond),
|
||||
Duration(12 * time.Hour),
|
||||
Duration(0),
|
||||
} {
|
||||
out, err := yaml.Marshal(map[string]Duration{"d": d})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %v: %v", d.D(), err)
|
||||
}
|
||||
var back struct {
|
||||
D Duration `yaml:"d"`
|
||||
}
|
||||
if err := yaml.Unmarshal(out, &back); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", out, err)
|
||||
}
|
||||
if back.D != d {
|
||||
t.Errorf("round trip %v via %q: got %v", d.D(), strings.TrimSpace(string(out)), back.D.D())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duration has no custom JSON unmarshaller; pin what encoding/json does with
|
||||
// it so a change here is a deliberate one.
|
||||
func TestDurationJSONForm(t *testing.T) {
|
||||
var got struct {
|
||||
D Duration `json:"d"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"d":5}`), &got); err != nil {
|
||||
t.Fatalf("bare number: %v", err)
|
||||
}
|
||||
if got.D.D() != 5*time.Nanosecond {
|
||||
t.Errorf("bare JSON number = %v (nanoseconds, not seconds)", got.D.D())
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"d":"5s"}`), &got); err == nil {
|
||||
t.Errorf("quoted JSON duration decoded to %v; string form is unsupported", got.D.D())
|
||||
}
|
||||
out, err := json.Marshal(Duration(5 * time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if string(out) != "5000000000" {
|
||||
t.Errorf("json.Marshal = %s, want nanoseconds", out)
|
||||
}
|
||||
}
|
||||
|
||||
// clearEnv removes name for the rest of the test, restoring it afterwards.
|
||||
func clearEnv(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if old, ok := os.LookupEnv(name); ok {
|
||||
t.Setenv(name, old) // registers the restore-on-cleanup
|
||||
}
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatalf("unset %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandEnvSubstitution(t *testing.T) {
|
||||
t.Setenv("FD_TEST_KEY", "s3cr3t")
|
||||
t.Setenv("FD_TEST_EMPTY", "")
|
||||
t.Setenv("FD_TEST_NESTED", "${FD_TEST_KEY}")
|
||||
clearEnv(t, "FD_TEST_MISSING")
|
||||
tests := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"double quoted scalar", `api_keys: ["${FD_TEST_KEY}"]` + "\n", `api_keys: ["s3cr3t"]` + "\n"},
|
||||
{"plain unquoted scalar", "cert_file: /etc/${FD_TEST_KEY}.pem\n", "cert_file: /etc/s3cr3t.pem\n"},
|
||||
{"single quoted scalar", "password: '${FD_TEST_KEY}'\n", "password: 's3cr3t'\n"},
|
||||
{"whole value", "password: ${FD_TEST_KEY}\n", "password: s3cr3t\n"},
|
||||
{"two references on one line", "x: ${FD_TEST_KEY}/${FD_TEST_KEY}\n", "x: s3cr3t/s3cr3t\n"},
|
||||
{"value is not rescanned", "x: ${FD_TEST_NESTED}\n", "x: ${FD_TEST_KEY}\n"},
|
||||
{"reference line comment", "# api_keys: ${FD_TEST_KEY}\n", "# api_keys: ${FD_TEST_KEY}\n"},
|
||||
{"reference trailing comment", "level: info # set ${FD_TEST_KEY} later\n", "level: info # set ${FD_TEST_KEY} later\n"},
|
||||
{"indented comment", "dns:\n # port ${FD_TEST_KEY}\n max_udp_size: 1232\n", "dns:\n # port ${FD_TEST_KEY}\n max_udp_size: 1232\n"},
|
||||
{"bare dollar in url", "url: https://x.invalid/?a=$b\n", "url: https://x.invalid/?a=$b\n"},
|
||||
{"bare dollar blocklist line", " url: https://h.invalid/hosts?$format=raw$other\n", " url: https://h.invalid/hosts?$format=raw$other\n"},
|
||||
{"bare dollar at end of line", "url: https://x.invalid/$\n", "url: https://x.invalid/$\n"},
|
||||
{"colon default when unset", "x: ${FD_TEST_MISSING:-fallback}\n", "x: fallback\n"},
|
||||
{"hyphen default when unset", "x: [${FD_TEST_MISSING-fallback}]\n", "x: [fallback]\n"},
|
||||
{"colon default when empty", "x: ${FD_TEST_EMPTY:-fallback}\n", "x: fallback\n"},
|
||||
{"hyphen default when empty stays empty", "x: [${FD_TEST_EMPTY-fallback}]\n", "x: []\n"},
|
||||
{"empty default", "x: [${FD_TEST_MISSING:-}]\n", "x: []\n"},
|
||||
{"empty value without default", "x: [${FD_TEST_EMPTY}]\n", "x: []\n"},
|
||||
{"hash without leading space is data", "x: a#${FD_TEST_KEY}\n", "x: a#s3cr3t\n"},
|
||||
{"hash inside double quotes is data", "x: \"# ${FD_TEST_KEY}\"\n", "x: \"# s3cr3t\"\n"},
|
||||
{"hash inside single quotes is data", "x: '# ${FD_TEST_KEY}'\n", "x: '# s3cr3t'\n"},
|
||||
{"crlf line ending", "x: ${FD_TEST_KEY}\r\ny: 1\r\n", "x: s3cr3t\r\ny: 1\r\n"},
|
||||
{"crlf comment", "# ${FD_TEST_KEY}\r\n", "# ${FD_TEST_KEY}\r\n"},
|
||||
{"no trailing newline", "x: ${FD_TEST_KEY}", "x: s3cr3t"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := expandEnv([]byte(tc.in))
|
||||
if err != nil {
|
||||
t.Fatalf("expandEnv(%q): %v", tc.in, err)
|
||||
}
|
||||
if string(got) != tc.want {
|
||||
t.Errorf("expandEnv(%q)\n got %q\nwant %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandEnvMultiLine(t *testing.T) {
|
||||
t.Setenv("FD_TEST_KEY", "abc123")
|
||||
clearEnv(t, "FD_TEST_MISSING")
|
||||
in := strings.Join([]string{
|
||||
"# leading comment ${FD_TEST_KEY}",
|
||||
"api:",
|
||||
" auth:",
|
||||
` api_keys: ["${FD_TEST_KEY}"] # trailing ${FD_TEST_KEY}`,
|
||||
" username: ${FD_TEST_MISSING:-admin}",
|
||||
" password: ${FD_TEST_MISSING-none}",
|
||||
"",
|
||||
"metrics:",
|
||||
" enabled: false # ${FD_TEST_MISSING} in a comment is fine",
|
||||
"",
|
||||
}, "\n")
|
||||
want := strings.Join([]string{
|
||||
"# leading comment ${FD_TEST_KEY}",
|
||||
"api:",
|
||||
" auth:",
|
||||
` api_keys: ["abc123"] # trailing ${FD_TEST_KEY}`,
|
||||
" username: admin",
|
||||
" password: none",
|
||||
"",
|
||||
"metrics:",
|
||||
" enabled: false # ${FD_TEST_MISSING} in a comment is fine",
|
||||
"",
|
||||
}, "\n")
|
||||
got, err := expandEnv([]byte(in))
|
||||
if err != nil {
|
||||
t.Fatalf("expandEnv: %v", err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("expandEnv multi-line\n got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandEnvWithoutReferencesIsByteIdentical(t *testing.T) {
|
||||
raw := []byte("# no references here\nurl: https://x.invalid/?a=$b&c=${not_this}\n'# ${FD_TEST_KEY}'\n")
|
||||
clearEnv(t, "FD_TEST_KEY")
|
||||
got, err := expandEnv(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expandEnv: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, raw) {
|
||||
t.Errorf("expandEnv changed a document with no references:\n got %q\nwant %q", got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandEnvErrors(t *testing.T) {
|
||||
clearEnv(t, "FD_TEST_MISSING")
|
||||
t.Setenv("FD_TEST_KEY", "abc123")
|
||||
tests := []struct {
|
||||
name, in string
|
||||
mentions []string
|
||||
}{
|
||||
{"unset without default", "x: ${FD_TEST_MISSING}\n", []string{"FD_TEST_MISSING", "line 1:"}},
|
||||
{"unset on third line", "a: 1\nb: 2\nc: ${FD_TEST_MISSING}\n", []string{"FD_TEST_MISSING", "line 3:"}},
|
||||
{"empty-but-set without default", "x: [${FD_TEST_MISSING}]\n", []string{"FD_TEST_MISSING"}},
|
||||
{"unterminated reference", "x: ${FD_TEST_KEY\n", []string{"unterminated ${"}},
|
||||
{"unterminated on later line", "a: 1\nb: ${FD_TEST_KEY\n", []string{"unterminated ${", "line 2:"}},
|
||||
{"empty name", "x: ${}\n", []string{"empty variable name in ${}"}},
|
||||
{"empty name with colon default", "x: ${:-fallback}\n", []string{"empty variable name"}},
|
||||
{"unterminated inside comment is ignored", "# x: ${FD_TEST_KEY\ny: 1\n", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := expandEnv([]byte(tc.in))
|
||||
if len(tc.mentions) == 0 {
|
||||
if err != nil {
|
||||
t.Fatalf("expandEnv(%q) = %v, want success", tc.in, err)
|
||||
}
|
||||
if string(got) != tc.in {
|
||||
t.Errorf("expandEnv(%q) = %q, want it copied verbatim", tc.in, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expandEnv(%q) = %q, want error", tc.in, got)
|
||||
}
|
||||
for _, m := range tc.mentions {
|
||||
if !strings.Contains(err.Error(), m) {
|
||||
t.Errorf("error %q does not mention %q", err, m)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubstitutesEnvInFile(t *testing.T) {
|
||||
t.Setenv("FD_TEST_KEY", "long-random-api-key")
|
||||
t.Setenv("FD_TEST_PW", "p@ss")
|
||||
path := writeTempConfig(t, `api:
|
||||
auth:
|
||||
mode: apikey
|
||||
api_keys: ["${FD_TEST_KEY}"]
|
||||
username: 'admin-${FD_TEST_PW}'
|
||||
password: prefix-${FD_TEST_PW}
|
||||
# the key is documented here: ${FD_TEST_KEY}
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
want := []string{"long-random-api-key"}
|
||||
if len(cfg.API.Auth.APIKeys) != 1 || cfg.API.Auth.APIKeys[0] != want[0] {
|
||||
t.Errorf("api_keys = %q, want %q", cfg.API.Auth.APIKeys, want)
|
||||
}
|
||||
if cfg.API.Auth.Username != "admin-p@ss" {
|
||||
t.Errorf("username = %q, want admin-p@ss", cfg.API.Auth.Username)
|
||||
}
|
||||
if cfg.API.Auth.Password != "prefix-p@ss" {
|
||||
t.Errorf("password = %q, want prefix-p@ss", cfg.API.Auth.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUnsetEnvIsFatalAndNamesVariable(t *testing.T) {
|
||||
clearEnv(t, "FD_TEST_MISSING")
|
||||
path := writeTempConfig(t, "api:\n auth:\n mode: apikey\n api_keys: [\"${FD_TEST_MISSING}\"]\n")
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("Load: want error for unset variable")
|
||||
}
|
||||
for _, want := range []string{"FD_TEST_MISSING", path} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not mention %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
Reference in new issue
Block a user