feat: validate configuration on startup, fail fast on bad config (closes #52) #53
Reference in New Issue
Block a user
Delete Branch "feature/config-validation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
closes #52
Implements startup configuration validation per the plan on #52. Two commits, TDD: the first commit adds the enforcement tests (red — six test functions fail against the lenient behavior) plus a mechanical extraction of
newFromSmartConfigfromconfig.Newso construction is testable without fx; the second commit makes them green and carries theTODO.mdbookkeeping.Behavior
getString/getInt/getBoolhelpers swallowed every conversion error and returned the default; they are now strict. Fractional ports are rejected, not truncated (smartconfig'sGetIntwould have turned8080.5into8080).metricssubkeys are fatal, each named in the error (unknown config keys: whitelist_hosts). Theenvsection stays permitted because smartconfig consumes it for environment injection.portin 1-65535;upstream_connections_per_hostat least 1;signing_keyrequired, at least 32 characters (keyless mode was never implemented; the stale "leave empty" comment inconfig.example.ymlis corrected);allowlist_hostsentries must be bare hostnames (leading-dot suffix patterns still allowed; schemes, paths, whitespace, non-string and empty entries rejected);state_dirnon-empty and verified creatable+writable with a probe file before the listener binds;sentry_dsnmust be a URL with scheme and host when set;metrics.username/metrics.passwordmust be set together.Verification
make checkgreen on the branch head (all tests, golangci-lint 0 issues, fmt-check clean)../bin/pixadwithport: bananaexits 1 printingconfig key "port": value "banana" is not an integer; withwhitelist_hosts:it exits 1 printingunknown config keys: whitelist_hosts.Notes for review
getStringSlicekeeps its lenient signature because the existing tests inconfig_test.goexercise it and modifying existing tests requires explicit approval. Strictness forallowlist_hostsis instead enforced up front on the raw value byvalidateAllowlistHostsValue, so nothing is silently skipped; extraction then reuses the existing parser. If you prefer the helper folded into a single strict function, that requires retargeting those three tests — happy to do that as a follow-up with approval.TODO.mdhere is edited against currentmain; PR #50 (merge-ready) edits adjacent lines, so whichever merges second will need a trivial rebase ofTODO.mdonly.access_control_allow_origin,upstream_fetch_timeout,upstream_max_response_size,downstream_timeout). Under this change a config using them now fails fast instead of silently doing nothing — that is the intended behavior. Implementing them is already tracked as the P2 "add all configuration options from README" item inTODO.md.FAIL — needs-rework
Adversarial review of head
2fb0801against the definition of done in #52 (issue body plus the plan comment) and the repo's no-silent-fallback rule.Blocking
CI is red on the head commit, caused by this PR's code.
check / check (push)fails on2fb0801("Failing after 42s", actions run 61): the Dockerfile lint stage (golangci-lint v2.10.1-alpine, hash-pinned) reportsinternal/config/config.go:209:21: G703: Path traversal via taint analysis (gosec)onos.Remove(probePath)in the newensureStateDirWritable, andmake lintexits nonzero, failing the docker build. The PR body's "make check green" claim reproduces locally only because a newer golangci-lint (v2.12.2 via nix-shell) does not emit this finding; the pinned CI image is the authoritative gate and per repo rules the linter output is to be treated as legitimate. Acceptable: satisfy the taint check in code (the file already has precedent atinternal/config/config.go:307, whereos.Staton config paths carries a justified//nolint:gosec // G703comment;probePathcomes fromos.CreateTempinside the just-validated directory, so an equivalent justified suppression or afilepath.Clean/containment check would be defensible). Do not touch.golangci.yml.Explicitly-null config values silently fall back to defaults, violating the PR's own core rule. The strict getters treat present-but-null identically to absent:
internal/config/config.go:380(getString),:402(getInt),:439(getBool) all doif !ok || raw == nil { return defaultVal }, andvalidateAllowlistHostsValue(:468) /getStringSlice(:514) do the same. Verified end-to-end with the built binary: a config containingport: null(equivalently a bareport:line — a classic truncated/typo'd entry) starts the server on port 8080;debug: ~,state_dir: null,allowlist_hosts: null,db_url: nulllikewise boot on defaults. These keys are SET; null is not a valid port/bool/path, and issue #52 DoD items 1 and 3 say a set-but-invalid value must abort naming the key. The behavior is also internally inconsistent, which shows it is an accident rather than a decided semantic:metrics: nullaborts (config.go:148-153, "value <nil> is not a map of metrics settings") andstate_dir: ""aborts, whilestate_dir: nullsilently defaults. Fix is feasible: smartconfig'sGetreturns(nil, true)for a top-level key present with a null value, so absent vs null is distinguishable. Acceptable: error on present-with-null naming the key, with tests; or, if the owner explicitly decides null-means-omitted, document it and apply it consistently (includingmetrics) with tests — that decision needs owner sign-off, not a reviewer's.Non-blocking
db_url: ""explicitly set silently derivesfile:<state_dir>/state.sqlite3?...(internal/config/config.go:117-120). Same family as finding 2 (an explicitly empty set value taking a computed default), and inconsistent withstate_dir: ""which aborts. Pre-existing derivation design, so not held blocking on its own, but it should either error or be explicitly documented as the "derive" sentinel.validateAllowlistHost(internal/config/config.go:271-279) accepts the entry".".allowlist.Newfiles it as a suffix pattern andIsAllowedmatches viastrings.HasSuffix(host, "."), so any upstream URL written in FQDN trailing-dot form (http://evil.com./x,Hostname()=evil.com.) is allowed without a signature — a single-character config entry that effectively disables signing. Requires the operator to configure"."themselves, and the matcher is pre-existing, so non-blocking — but the new validator is the right place to reject"."(and arguably any entry lacking a label).Error-message consistency: the
signing_keyerrors (config.go:221,:227) use bare phrasing ("signing_key is required") instead of theconfig key %qconvention used everywhere else. They do name the key, and correctly avoid echoing the secret value; cosmetic only.The
metrics: nullerror printsvalue <nil> is not a map of metrics settings— the rendered<nil>is unhelpful to an operator; cosmetic.Notes
getStringSliceretention: claim verified. Its only non-test caller isnewFromSmartConfig(config.go:110), and whenever a config file exists,validateAllowlistHostsValueruns first (config.go:94-96). Empirically,allowlist_hosts: "a,,b", non-string list entries, and non-list values all abort namingallowlist_hostsand the value; nothing is silently skipped on the strict path. Retention is an owner-decision note, not a defect. (The null case is covered under blocking finding 2, not this helper.)f19da2c(tests-first commit) all six new test functions fail (25 failing subtests observed); at head all pass. The only inter-commit change to the new test file is gofmt column alignment (whitespace-only). Existing tests (internal/config/config_test.go) are byte-identical tomain.make checkresult: green locally at head — tests pass, golangci-lint v2.12.2 reports 0 issues, gofmt clean (run via the Makefile's own nix-shell dependency mechanism; this environment lacks vips andmainfails identically without it, so that part is environmental). The divergence from CI's pinned v2.10.1 is exactly blocking finding 1.port: banana/0/99999/8080.5/[8080], duplicateport:keys (yaml.v3 rejects duplicates, now fatal),debug: 1,allow_http: 2/yes(YAML 1.2 string, ParseBool rejects — strict, correct), numericsigning_key, short/missingsigning_key,metricsusername-only/password-only/non-map,sentry_dsnwithout scheme/host, unknown top-level key and unknownmetricssubkey (each named), malformed YAML at a standard location, uncreatablestate_dir(/dev/null/subdirfails on the probe pre-listen).(closes #52); no attribution trailers; branch mergeable against currentmain(base6573b9dis themaintip).TODO.mdbookkeeping matches the Workflow section, and theconfig.example.ymlstale-comment fix is in scope. Godoc present on the new identifiers (all unexported); naming is descriptive and consistent; no linter-config, vendor, or binary changes.Verdict: FAIL. Label
needs-rework(the CI redness is caused by in-PR code, so a rerun will not go green without changes). Findings 1 and 2 must be fixed; 3-6 at author/owner discretion.Manager note: independent adversarial review returned FAIL (see review comment above). Relabeling
needs-reworkand dispatching rework limited to the two blocking findings:internal/config/config.go:209(os.Remove(probePath)inensureStateDirWritable) in code — a justified//nolint:gosecmatching the existing precedent at config.go:307, or a containment check. The linter config must not be touched.port: null, bareport:,debug: ~, etc.) abort startup naming the key, instead of silently defaulting. An explicit null is a SET value under the repo's no-silent-fallback policy, and current behavior is internally inconsistent (metrics: nullaborts whileport: nulldefaults), so this is a defect fix, not a semantics decision.Non-blocking review notes (
db_url: ""silent derivation, allowlist"."trailing-dot-FQDN bypass, error-message style,<nil>in the metrics-null message) may be fixed in the same pass if trivial and in scope; otherwise they remain recorded here.The retained lenient
getStringSlicehelper was verified by the reviewer to be fully guarded byvalidateAllowlistHostsValueand is an owner decision, not a defect; it does not block this PR. Question for sneak, with recommendation: keep the helper as-is (recommended — the strictness guarantee holds and folding it into a single strict function would require retargeting three existing tests, which needs explicit approval), or approve a follow-up that retargets those tests and removes the lenient signature. No action needed to merge this PR either way.After rework, a fresh independent reviewer will re-review before any
merge-readylabeling.Entries consisting only of dots (".", "..") are now a startup error naming the key and entry. Previously a bare "." passed validation and became a suffix pattern matching every trailing-dot FQDN upstream, bypassing URL signing.Rework complete against the FAIL review (issuecomment-43803) and the manager disposition (issuecomment-43807). New head
22f19c8.Blocking finding 1: CI red on gosec G703 (pinned golangci-lint v2.10.1)
Fixed in code only, in
11e9206:os.Remove(probePath)inensureStateDirWritablenow carries//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir, matching the existing justified-suppression precedent inloadConfigFile..golangci.ymlis untouched. Verified against the authoritative pinned linter by building the Dockerfile lint stage (docker build --target lint ., golangci-lint v2.10.1-alpine):RUN make lintreports0 issueson the final head. The local newer linter also reports 0 issues (the directive is not flagged as unused).Blocking finding 2: explicitly-null values silently defaulted
Fixed TDD in
3705459(13 failing subtests committed first:port: null, bareport:,debug: ~,maintenance_mode,allow_http,state_dir,db_url,sentry_dsn,upstream_connections_per_host,allowlist_hosts,signing_key,metrics: null, and nullmetricssubkeys) andc0d3251(fix). An explicit null is a SET value and now aborts startup naming the key:validateKnownKeyscollects null-valued keys (top level plusmetricssubkeys) and errors on each, sorted and named (config key "port": value is null; omit the key entirely to use the default); the strict getters (getString/getInt/getBool) andvalidateAllowlistHostsValuealso error on null instead of defaulting, as defense in depth. Behavior is now consistent across every key. End-to-end with the built binary:port: nullexits 1 printingconfig key "port": value is null; omit the key entirely to use the default; a valid config still boots.Non-blocking notes
db_url: ""silent derivation): fixed, TDD, in813ff63(failing test) and5297e60(fix). The derived sqlite URL is now applied only whendb_urlis omitted; an explicitly empty value aborts (config key "db_url": value must not be empty; omit the key to derive it from state_dir), matchingstate_dir: "". Verified end-to-end."."trailing-dot-FQDN bypass): fixed, TDD, in808356f(failing tests for.and..) and83fa228(fix).validateAllowlistHostrejects entries containing no hostname labels:config key "allowlist_hosts": entry "." contains no hostname labels. Verified end-to-end.22f19c8— both errors now use theconfig key %qconvention; the secret value is still never echoed.<nil>rendering formetrics: null): fixed as a side effect of finding 2 —metrics: nullnow takes the null-specific message instead ofvalue <nil> is not a map of metrics settings.Also in this push
745a461merges currentmaininto the branch: PR #50 merged after this PR opened and both editTODO.md, so the PR had become unmergeable. Conflict resolution isTODO.md-only, exactly the trivial rebase the PR body predicted: both Completed entries kept, and the conflicted Future Steps lines dropped (cache-size moved to Next Step bymain; config validation completed by this PR).Verification
make checkgreen on head22f19c8(all tests pass including the 13 new null subtests and the 2 new TDD pairs; golangci-lint 0 issues; fmt-check clean), and the pinned-CI-version Dockerfile lint stage passes with 0 issues. No existing tests were modified; all additions are new test functions. Labels/assignees left unchanged for the manager to dispatch re-review.PASS
Fresh independent adversarial re-review of head
22f19c8against the definition of done in #52 (issue body plus plan comment), the first review (issuecomment-43803), the manager disposition (issuecomment-43807), and the rework summary (issuecomment-43887). All claims re-verified from scratch in a temporary worktree; nothing taken on prior reviewers' or the author's word.Prior blocking findings — confirmation status
G703 / CI red: CONFIRMED FIXED.
internal/config/config.go:253now carries//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir, matching the existing justified-suppression precedent atconfig.go:363. Verified against the authoritative pinned linter with an UNCACHEDdocker build --target lint .(golangci-lint v2.10.1-alpine, hash-pinned):make fmt-checkpasses andmake lintreports0 issues, exit 0..golangci.yml,Makefile,Dockerfile, andscript/are byte-identical tomain(diff empty). CI is green on the head commit:check / check (push)success (actions run 63, 1m48s).Explicit-null silent fallback: CONFIRMED FIXED. Verified end-to-end with a freshly built binary:
port: null, bareport:,debug: ~,state_dir: null,allowlist_hosts: null,db_url: null,sentry_dsn: null,metrics: null, and nullmetricssubkeys (both, and mixedusername: bob+password: null) each exit 1 before binding, naming the key:config key "port": value is null; omit the key entirely to use the default; multiple nulls are collected, sorted, and all named (config keys metrics.password, metrics.username: ...). Defense in depth is present in the strict getters (config.go:441,:467,:509) andvalidateAllowlistHostsValue(config.go:542) in addition tovalidateKnownKeys(config.go:159-183). A valid config still boots and serves HTTP 200. No<nil>artifacts appear in any observed error message.Blocking
None.
Non-blocking
None new. The four non-blocking notes from the first review are all verified fixed at this head:
db_url: ""exits 1 withconfig key "db_url": value must not be empty; omit the key to derive it from state_dir; omitteddb_urlstill derivesfile:<state_dir>/state.sqlite3?_journal_mode=WAL(unit-tested inTestOmittedValuesUseDefaults, passing)..and..exit 1 withconfig key "allowlist_hosts": entry "." contains no hostname labels; a legitimate leading-dot suffix entry (.example.org) still loads and the server boots.signing_keyerrors now use theconfig key %qconvention (config.go:267,:273); the secret string value is still never echoed.metrics: nulltakes the null-specific message instead of the oldvalue <nil>rendering.Notes
make check: green at head. All packages pass (including the new validation tests), golangci-lint 0 issues, fmt-check clean, exit 0. (One environmental artifact worth recording for other reviewers: a stale shared golangci-lint analysis cache from a deleted sibling worktree of this module replays phantom issues against../pixa-rework/...paths; a freshGOLANGCI_LINT_CACHEreproduces the true result. None of the phantom findings involve this PR's files.)docker build --target lint .—0 issues, exit 0, as stated above.internal/config/config_test.gois byte-identical tomain; the PR's only test changes are the newinternal/config/config_validation_test.go. Verified across the whole PR range.3705459the 13 null subtests fail (observed red) and atc0d3251the full suite passes (observed green);813ff63fails onTestExplicitlyEmptyDBURLAbortsStartup(red) and808356ffails on both dot-entry subtests (red), with head green. The fix commits change zero test lines in each pair.745a461clean. Relative to its branch parent it touches onlyTODO.md; relative tomainit is exactly the PR's intended bookkeeping (Completed entry added, the Future Steps P0 line removed). No code smuggled in. PR is mergeable; base5d0b5f8is the currentmaintip.port: banana/0/99999/8080.5, duplicateport:keys (yaml duplicate-key parse error, fatal),debug: 1,upstream_connections_per_host: 0/-3, numericsigning_key, short/missingsigning_key,metricsusername-only, unknown top-level key (whitelist_hosts), unknownmetricssubkey (metrics.port), unknown-key + null-key mix (unknown reported first — reasonable precedence), malformed YAML file,state_dir: "", uncreatablestate_dir(/dev/null/sub),sentry_dsn: "not a url", allowlist entry with scheme, comma-list with empty entry ("a,,b"), andenv: null(fatal via smartconfig:env section must be a map).metrics: {}boots (empty section, both credentials omitted — consistent with the set-together rule).allowlist_hosts: ""(explicitly empty string) loads as an empty allowlist and boots. This is a defensible explicit value (empty list, same as[]— every upstream requires a signature), not a silent fallback to a different value; recorded for completeness only.getStringSliceretention guard re-verified at this head:validateAllowlistHostsValueruns first whenever a config file exists (config.go:94-96) and now also rejects null; nothing reaches the lenient extraction unvalidated. Owner-decision item, not a defect, per the manager disposition.(closes #52); no attribution trailers or vendor/binary changes anywhere in the range; godoc present on new identifiers; naming descriptive and consistent;TODO.mdmatches the Workflow section.Verdict: PASS. Eligible for
merge-readyand assignment to sneak; labels/assignees left to the manager per dispatch.Manager note: fresh independent re-review at head
22f19c8returned PASS (see review comment above) — both prior blocking findings confirmed fixed, pinned CI lint gate at 0 issues, CI green (actions run 63), full hostile-config sweep clean, no existing tests modified, mergeable against currentmain(the PR #50 merge conflict was resolved in745a461,TODO.md-only). Labelingmerge-readyand assigning to sneak for merge (protectedmain).One open owner question, non-blocking (does not affect merging this PR): keep the lenient
getStringSlicehelper as-is (recommended — its guard viavalidateAllowlistHostsValuewas re-verified at this head), or approve a follow-up that folds it into a single strict function, which requires retargeting three existing tests ininternal/config/config_test.go.Decision on the open
getStringSlicequestion (owner delegated the call): keep the helper as-is. Rationale: the no-silent-fallback guarantee is enforced up front byvalidateAllowlistHostsValuebefore the helper ever runs, and that guard was independently verified by two separate adversarial reviews (issuecomment-43803 and issuecomment-43938), including at the merged head. Folding the helper into a single strict function would change zero observable behavior while churning three existing tests ininternal/config/config_test.go. No follow-up issue; question closed.