All 35 findings fixed in 544a6dd and marked Status: Resolved with resolution
notes. README regenerated: 0 pending / 35 total across 6 libraries.
12 KiB
Code Review — Configuration
| Field | Value |
|---|---|
| Library | ZB.MOM.WW.Configuration/ |
| Packages | ZB.MOM.WW.Configuration |
| Component spec | components/configuration/spec/SPEC.md |
| Shared contract | components/configuration/shared-contract/ZB.MOM.WW.Configuration.md |
| Status | Reviewed |
| Last reviewed | 2026-06-01 |
| Reviewer | Claude (automated baseline) |
| Commit reviewed | 5f75cd4 |
| Open findings | 0 |
Summary
The library is small (five source files, ~230 LOC) and in good health. Its core promise —
accumulate every failure, never short-circuit — holds across all three entry points
(ValidationBuilder, OptionsValidatorBase, ConfigPreflight): the override never returns
early, each primitive appends to a List<string> and returns this, and the Success/Fail
decision is taken only after every rule has run. The boundary cases that matter are correct:
Port rejects 0 and 65536 and accepts 1/65535; HostPort correctly rejects bare hosts, empty
ports, out-of-range ports, and (deliberately) unbracketed IPv6; Required treats null / empty /
whitespace alike; OneOf is case-insensitive and treats null as a failure (documented);
MinCount treats null as zero. Null-guarding is consistent (ArgumentNullException/
ArgumentException.ThrowIfNullOrWhiteSpace on every public entry point). The
ConfigPreflight.ThrowIfInvalid() envelope is byte-compatible with the ScadaBridge
StartupValidator format the SPEC pins ("Configuration validation failed:\n - <field> <reason>",
\n-joined), and the validator created by OptionsValidatorBase is stateless (fresh
ValidationBuilder per call), so the singleton-registration requirement in SPEC §3 is honoured.
Dependency footprint is minimal and exactly as documented (four Microsoft.Extensions.*
abstractions, no ASP.NET Core). The four findings are all Low: a non-idempotent DI
registration (AddSingleton vs TryAddEnumerable), a small wording inconsistency in the raw-port
check, over-permissive integer port parsing, and the XML-doc-not-shipped packaging gap (which is
fleet-wide, not specific to this library). Test coverage (27 tests across the four public surfaces)
is solid for the happy and primary-failure paths; a few edge cases noted below are untested.
Checklist coverage
| # | Category | Examined | Notes |
|---|---|---|---|
| 1 | Correctness & logic bugs | ☑ | Accumulation never short-circuits; port/host:port/duration/min-count boundaries correct. Port parsing over-permissive (003). |
| 2 | Public API surface & compatibility | ☑ | Surface matches the shared contract exactly; nullability annotations correct; sealed/abstract deliberate; internal Checks does not leak. No issues. |
| 3 | Concurrency & thread safety | ☑ | Validator is stateless (fresh builder per Validate); ValidationBuilder/ConfigPreflight are per-call locals. Safe as a singleton. No issues. |
| 4 | Error handling & resilience | ☑ | Guard clauses on every public entry; correct exception types (OptionsValidationException via ValidateOnStart, InvalidOperationException from ConfigPreflight); no swallowed exceptions. No issues. |
| 5 | Security & secret handling | ☑ | No secrets handled; failure messages echo config values (port/host/role), which are non-sensitive by design. No issues. |
| 6 | Performance & resource management | ☑ | Startup-only, no hot path, nothing disposable, no async. No issues. |
| 7 | Spec & shared-contract adherence | ☑ | ConfigPreflight envelope byte-compatible with StartupValidator; primitives match §2 table. Minor wording inconsistency in PortValue (002). |
| 8 | Packaging, dependencies & project layout | ☑ | Single package, minimal closure, central versions, .gitignore present, no tracked build output. XML docs / README not packaged (004). |
| 9 | Testing coverage | ☑ | 27 tests cover the four public surfaces + accumulation + byte-format. Untested: AddValidatedOptions null guards, double-registration, exact wording strings. |
| 10 | Documentation & XML docs | ☑ | Public XML docs present and accurate on every type/member. Not emitted into the nupkg (004). |
Findings
Configuration-001 — AddValidatedOptions uses AddSingleton, so a double call registers (and runs) the validator twice
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs:36 |
Description
AddValidatedOptions registers the validator with
services.AddSingleton<IValidateOptions<TOptions>, TValidator>(). IValidateOptions<TOptions>
is a multi-registration (enumerable) service — the options pipeline resolves all registered
IValidateOptions<TOptions> and runs each. Because AddSingleton appends unconditionally, calling
AddValidatedOptions<TOptions, TValidator> twice for the same TOptions/TValidator pair (e.g.
two modules both guarding the same section, or an accidental duplicate during refactoring)
registers the validator twice, so it executes twice and every accumulated failure is reported
twice in the resulting OptionsValidationException. The framework-idiomatic registration for
IValidateOptions<T> is TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>, TValidator>()), which is idempotent. Impact is limited (consumers normally call once per section,
and duplicate messages are cosmetic, not a correctness break of the accumulate-all contract), hence
Low — but the deviation from the .NET idiom is real and easy to fix. The SPEC §3 wording ("registers
the validator as a singleton") is satisfied either way.
Recommendation
Replace services.AddSingleton<IValidateOptions<TOptions>, TValidator>() with
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>, TValidator>())
(via Microsoft.Extensions.DependencyInjection.Extensions). Add a test asserting that calling
AddValidatedOptions twice yields a single set of failure messages.
Resolution
Resolved in 544a6dd, 2026-06-01 — AddValidatedOptions now registers the validator via TryAddEnumerable(ServiceDescriptor.Singleton<...>()), so a double call is idempotent (test: AddValidatedOptionsTests.Calling_twice_registers_validator_once).
Configuration-002 — Checks.PortValue quotes the raw value on a parse failure but not on a range failure
| Severity | Low |
| Category | Spec & shared-contract adherence |
| Status | Resolved |
| Location | ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs:21 |
Description
PortValue produces two different message shapes for the same field depending on why the port
is invalid. When the raw string parses but is out of range (e.g. "0" or "70000"), it delegates
to Checks.Port(int, field), which renders "<field> must be between 1 and 65535 (was 0)" —
unquoted. When the raw string does not parse (e.g. "notaport", null), it renders
"<field> must be between 1 and 65535 (was 'notaport')" — quoted. So two failures of the same
rule, from the same raw-config caller, read with inconsistent quoting. The shared contract
(shared-contract/ZB.MOM.WW.Configuration.md, "Internal Checks seam") states Checks is "the
single source of failure wording" so "a port failure reads the same whether it came from a bound
options object or a raw config key" — this inconsistency is in mild tension with that claim. It is
cosmetic (no consumer parses the text) and the overlapping integer case does match the bound-options
wording, so it is Low.
Recommendation
Make the two branches use a consistent quoting convention for the offending value — e.g. have the
range-failure branch also quote ((was '0')), or have the parse-failure branch route through a
shared formatter. Lock the exact strings down with a wording assertion test.
Resolution
Resolved in 544a6dd, 2026-06-01 — Checks.PortValue now quotes the offending raw value on both the parse-failure and range-failure branches ((was '0')), wording pinned by test (test: ChecksWordingTests.PortValue_range_failure_quotes_the_value).
Configuration-003 — Port parsing accepts leading sign and surrounding whitespace and is culture-sensitive
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs:22, ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs:36 |
Description
Both PortValue (int.TryParse(raw, out var port)) and HostPort
(int.TryParse(value[(idx + 1)..], out var port)) use the default int.TryParse overload, which
applies NumberStyles.Integer (AllowLeadingWhite | AllowTrailingWhite | AllowLeadingSign) and the
current culture. As a result strings the documentation describes as "integer TCP port" are accepted
more loosely than intended: "+5000", " 5000 ", and "host: 5000" (space after the colon) all
parse and pass, and parsing is locale-dependent. A leading - parses to a negative number that the
subsequent range check rejects (so "-1" is correctly rejected), but the whitespace/leading-+
cases silently pass. This is a robustness nuance rather than a security or correctness break — a port
that survives the loose parse is still in 1..65535 — so it is Low.
Recommendation
Parse with an explicit, culture-invariant, strict style, e.g.
int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var port) (rejects sign and
whitespace), in both PortValue and HostPort. Add Theory cases for "+5000", " 5000 ", and a
space-after-colon endpoint to pin the behaviour.
Resolution
Resolved in 544a6dd, 2026-06-01 — both PortValue and HostPort now parse with int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, ...), rejecting leading sign/whitespace and culture-dependent formats (test: ChecksWordingTests.PortValue_rejects_loose_inputs, HostPort_rejects_loose_port_inputs).
Configuration-004 — XML documentation and README are not packaged into the nupkg
| Severity | Low |
| Category | Documentation & XML docs |
| Status | Resolved |
| Location | ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj:1, ZB.MOM.WW.Configuration/Directory.Build.props:1 |
Description
Every public type and member carries accurate XML doc comments, but neither the project nor
Directory.Build.props sets <GenerateDocumentationFile>true</GenerateDocumentationFile>, so no
.xml doc file is produced or included in the dotnet pack output. Consumers of the package
therefore get no IntelliSense documentation for OptionsValidatorBase, ValidationBuilder,
AddValidatedOptions, or ConfigPreflight, despite the docs existing in source. The project also
does not set PackageReadmeFile, so the README is not embedded in the package for display on the
NuGet feed. Category 10 of the review process explicitly notes "these are libraries — public docs
matter," so the gap is worth recording. It is fleet-wide (the sibling ZB.MOM.WW.Audit library has
the same omission), not a Configuration-specific regression, hence Low.
Recommendation
Add <GenerateDocumentationFile>true</GenerateDocumentationFile> (ideally in
Directory.Build.props so the whole family inherits it) and consider <PackageReadmeFile>README.md</PackageReadmeFile>
plus packing the README. Treat as a shared-infra change across the six libraries rather than a
one-off.
Resolution
Resolved in 544a6dd, 2026-06-01 — GenerateDocumentationFile=true added to Directory.Build.props (test project opts out) and PackageReadmeFile/README pack item added to the csproj, so dotnet pack now ships ZB.MOM.WW.Configuration.xml and README.md in the nupkg.