ae0ccc9a3a
All 35 findings fixed in 544a6dd and marked Status: Resolved with resolution
notes. README regenerated: 0 pending / 35 total across 6 libraries.
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
# 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.
|