code-review: 2026-05-28 baseline re-review of all 23 modules at 1eb6e97

Re-applies the full 10-category checklist to every src/ project — including
first-time reviews of the four newer components (AuditLog, NotificationOutbox,
SiteCallAudit, Transport) — so the code-reviews/ index reflects today's
codebase rather than the 2026-05-16 baseline. 172 new Open findings (0
Critical, 18 High, 62 Medium, 92 Low); 481 findings total across 23 modules.

regen-readme.py now derives each module's Last reviewed + Commit from its
findings.md header instead of hard-coding 2026-05-16 / 9c60592, so future
single-module re-reviews show their own date in the Module Status table.
This commit is contained in:
Joseph Doherty
2026-05-28 02:55:47 -04:00
parent 1eb6e972b0
commit f93b7b99bb
25 changed files with 8793 additions and 115 deletions
+329 -3
View File
@@ -5,10 +5,10 @@
| Module | `src/ScadaLink.CLI` |
| Design doc | `docs/requirements/Component-CLI.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-17 |
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `39d737e` |
| Open findings | 0 |
| Commit reviewed | `1eb6e97` |
| Open findings | 7 |
## Summary
@@ -47,6 +47,36 @@ and `WriteAsTable` derives table columns from only the first array element, sile
dropping columns for any later element with a different shape (CLI-016). No
Critical/High issues; the module remains healthy.
#### Re-review 2026-05-28 (commit `1eb6e97`)
The CLI has grown two substantial new command groups since the last re-review —
`scadalink audit` (Audit Log #23 M8) and `scadalink bundle` (Transport #24) — together
adding ~1,500 lines of new production code. The new `audit` surface is well-tested and
well-factored (pure helpers + a clear `IAuditFormatter` seam), but the new `bundle`
surface is untested, duplicates the URL/credential resolution that already exists in
`CommandHelpers`, and inherits a partial authorization-exit-code regression that also
appears in the audit path. Two longstanding fragility gaps that the prior reviews missed
also surface in this pass: `CliConfig.Load` parses the config file with no try/catch, and
`CommandTreeTests` still pins the old 14-group count so the two new groups are excluded
from the leaf-action and registry-resolution coverage that protected the rest of the
tree. Module health is broadly good but the consolidated count is now seven Open
findings (none Critical, three Medium).
- **CLI-017** — `BundleCommands` duplicates `ExecuteCommandAsync` and skips the
`FORBIDDEN`/`UNAUTHORIZED` exit-code mapping (auth exit 2 contract regression).
- **CLI-018** — `AuditQueryHelpers.RunQueryAsync` / `AuditExportHelpers.RunExportAsync`
return exit 1 on every error, never the documented exit 2 for authorization failure.
- **CLI-019** — `BundleCommands.bundle export` decodes the entire base64 bundle in
memory and writes synchronously — 100 MB bundles double-buffer.
- **CLI-020** — `BundleCommands.bundle export` parses the success body with bare
`JsonDocument.Parse` + `GetProperty` and throws on a malformed/abbreviated envelope.
- **CLI-021** — `CliConfig.Load` crashes the whole CLI when `~/.scadalink/config.json`
is malformed or unreadable, even if `--url` was supplied on the command line.
- **CLI-022** — `AuditCommands` and `BundleCommands` are absent from `CommandTreeTests`;
the test still pins `Equal(14, groups.Count)` and silently excludes the new groups.
- **CLI-023** — `Component-CLI.md` says the audit commands ride `POST /management`,
but the implementation calls a new `GET /api/audit/*` REST endpoint pair.
## Checklist coverage
_Original review (2026-05-16, `9c60592`):_
@@ -79,6 +109,21 @@ _Re-review (2026-05-17, `39d737e`):_
| 9 | Testing coverage | ☑ | Substantially expanded (`CommandTreeTests`, `ManagementHttpClientTests`, `DebugStreamTests`). No new gaps. |
| 10 | Documentation & comments | ☑ | XML docs accurate. `Component-CLI.md` drift folded into CLI-015. |
_Re-review (2026-05-28, `1eb6e97`):_
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ☑ | `BundleCommands.BuildExport` unguarded `JsonDocument.Parse` + `GetProperty` (CLI-020); `CliConfig.Load` unguarded JSON parse (CLI-021). |
| 2 | Akka.NET conventions | ☑ | Not applicable — pure HTTP/SignalR/REST client. No issues. |
| 3 | Concurrency & thread safety | ☑ | No new concurrency surface; `debug stream` unchanged since CLI-011/012. No issues. |
| 4 | Error handling & resilience | ☑ | Bundle and audit paths skip the auth exit-code contract (CLI-017, CLI-018); bundle JSON-envelope parse is brittle (CLI-020); config-file parse aborts the process (CLI-021). |
| 5 | Security | ☑ | No new credential or trust-boundary issues. No issues. |
| 6 | Performance & resource management | ☑ | `bundle export` double-buffers the whole bundle in memory (CLI-019). |
| 7 | Design-document adherence | ☑ | `Component-CLI.md` claims audit commands ride `POST /management`; implementation uses new REST endpoints (CLI-023). |
| 8 | Code organization & conventions | ☑ | `BundleCommands.RunBundleCommandAsync` re-implements credential/URL resolution that `CommandHelpers.ExecuteCommandAsync` already provides — drift waiting to happen (CLI-017). |
| 9 | Testing coverage | ☑ | `BundleCommands` has no tests; `CommandTreeTests` pins `Equal(14, …)` and excludes the new `AuditCommands` + `BundleCommands` groups (CLI-022). |
| 10 | Documentation & comments | ☑ | XML docs accurate; doc-vs-code transport drift folded into CLI-023. No other issues. |
## Findings
### CLI-001 — `SCADALINK_FORMAT` env var and config-file format are dead; format precedence broken
@@ -741,3 +786,284 @@ list and `OutputFormatter.WriteTable` pads missing cells, so heterogeneous array
render every column. Regression tests added in `TableHeaderUnionTests` (3 tests:
later-element-only column included, first-seen column order preserved,
first-element-extra column still rendered).
### CLI-017 — `BundleCommands.RunBundleCommandAsync` duplicates `ExecuteCommandAsync` and breaks the auth exit-code contract
| | |
|--|--|
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Open |
| Location | `src/ScadaLink.CLI/Commands/BundleCommands.cs:244-289` (vs. `src/ScadaLink.CLI/Commands/CommandHelpers.cs:20-73`, `:159-174`) |
**Description**
`BundleCommands.RunBundleCommandAsync` re-implements the URL/credential resolution,
validation, and HTTP plumbing that `CommandHelpers.ExecuteCommandAsync` already provides
for every other command group — to attach a 5-minute timeout (`BundleCommandTimeout`)
and a caller-supplied success handler. In duplicating it, two contracts that
`CommandHelpers` carefully establishes were dropped:
1. **Authorization exit code.** `CommandHelpers.HandleResponse` routes through
`IsAuthorizationFailure`, which returns exit 2 for **either** HTTP 403 **or** an
`UNAUTHORIZED`/`FORBIDDEN` error code on any status (resolution of CLI-009). The
bundle path at line 287 uses a bare `if (response.StatusCode == 403) return 2;` — a
server that signals authorization failure via the `code` field on a non-403 status
(the same channel the rest of the CLI honours) will exit 1 instead of 2 from
`bundle export`/`preview`/`import`. `Component-Transport.md:289` explicitly states
"Exit codes follow the project convention: `0` = success, `1` = command failure,
`2` = authorization failure," so this is a contract regression.
2. **Error-message phrasing drift.** The two duplicated error paths
(`bundle:258-260`, `:264-266`) emit shorter messages that omit the
`SCADALINK_MANAGEMENT_URL` / `SCADALINK_USERNAME` env-var hints the canonical paths
give — confusing if the user is trying to debug what's missing.
**Recommendation**
Refactor `CommandHelpers.ExecuteCommandAsync` to accept an optional `TimeSpan` timeout
and an optional success handler, and have `BundleCommands` call it. Failing that,
extract `CommandHelpers.IsAuthorizationFailure` to `internal` and call it from
`RunBundleCommandAsync` in place of the bare 403 check, and copy the canonical error
messages verbatim.
**Resolution**
_Unresolved._
### CLI-018 — `audit query` and `audit export` never return exit 2 for an authorization failure
| | |
|--|--|
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Open |
| Location | `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs:186-193`, `src/ScadaLink.CLI/Commands/AuditExportHelpers.cs:147-153` |
**Description**
The two audit-log subcommands (`audit query`, `audit export`) ride a new REST surface
(`GET /api/audit/query` and `GET /api/audit/export`) — not the `POST /management`
envelope that goes through `CommandHelpers.HandleResponse`. Both helpers map *any*
non-success response to a generic `OutputFormatter.WriteError(...)` + `return 1`:
- `AuditQueryHelpers.RunQueryAsync:186-193` returns 1 unconditionally when `JsonData`
is null (i.e. any error). It never inspects `StatusCode` or `ErrorCode`.
- `AuditExportHelpers.RunExportAsync:147-153` returns 1 for every non-success status,
again with no 403 / `FORBIDDEN` carve-out.
`Component-CLI.md:295-296` documents exit code 2 for "Authorization failure (insufficient
role)". `Component-AuditLog.md` (Security & Tamper-Evidence) and `Component-CLI.md:184-187`
both call out that the audit endpoints are gated by `OperationalAudit` and `AuditExport`
permissions enforced server-side — i.e. these are exactly the commands most likely to
return 403 in routine use. The exit-code regression silently downgrades a 403 to a
generic command failure, breaking the CI/CD scripting contract.
**Recommendation**
Promote `CommandHelpers.IsAuthorizationFailure` to `internal` (or move it to a small
shared helper) and have `RunQueryAsync` / `RunExportAsync` return 2 when it matches.
The check needs to use the `ManagementResponse.StatusCode` / `ErrorCode` pair the
audit `SendGetAsync` already populates.
**Resolution**
_Unresolved._
### CLI-019 — `bundle export` decodes the entire base64 bundle into memory before writing
| | |
|--|--|
| Severity | Medium |
| Category | Performance & resource management |
| Status | Open |
| Location | `src/ScadaLink.CLI/Commands/BundleCommands.cs:117-124`, `src/ScadaLink.CLI/ManagementHttpClient.cs:47-92` |
**Description**
`Component-Transport.md:271` ceilings the raw bundle at 100 MB and notes the
per-request body cap is raised to 200 MB once base64-inflated. The CLI's export path
goes through `ManagementHttpClient.SendCommandAsync`, which reads the entire response
body into a string (`responseBody = await httpResponse.Content.ReadAsStringAsync(...)`)
and returns it as `ManagementResponse.JsonData`. `BundleCommands.BuildExport` then:
1. `JsonDocument.Parse(jsonOk)` re-allocates the JSON DOM (~200 MB string + DOM).
2. `doc.RootElement.GetProperty("base64Bundle").GetString()` materializes the base64
payload as another ~200 MB `string`.
3. `Convert.FromBase64String(base64)` allocates a fresh ~100 MB `byte[]`.
4. `File.WriteAllBytes(output, bytes)` writes synchronously.
Peak working-set for a 100 MB bundle is therefore ~600 MB, all on the LOH, plus the
file-I/O is fully synchronous. The streaming `SendGetStreamAsync` path the audit
export uses (line 155-156) shows the right pattern is already available for plain GETs,
but bundles ride a `POST /management` envelope so they currently can't reuse it.
**Recommendation**
For the export path specifically, add a streaming variant — either a new
`POST /api/bundle/export` REST endpoint mirroring the audit pattern, or a chunk-fetch
follow-up `GET /api/bundle/<exportId>` so the CLI can stream bytes through
`Stream.CopyToAsync` without buffering the whole envelope. If a v1 stop-gap is needed,
at minimum switch to `File.WriteAllBytesAsync` and use `Convert.TryFromBase64Chars`
with a rented buffer to avoid the double-LOH allocation.
**Resolution**
_Unresolved._
### CLI-020 — `bundle export` success-envelope parse is unguarded
| | |
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `src/ScadaLink.CLI/Commands/BundleCommands.cs:117-126` |
**Description**
The export success handler does:
```csharp
using var doc = JsonDocument.Parse(jsonOk);
var base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!;
var byteCount = doc.RootElement.GetProperty("byteCount").GetInt32();
var bytes = Convert.FromBase64String(base64);
```
None of these calls are wrapped in a `try/catch`. A server-side bug that omits one of
the two properties, returns a `null` `base64Bundle`, sends invalid base64, or sends a
malformed JSON envelope will surface as one of `KeyNotFoundException` /
`InvalidOperationException` / `FormatException` — an unhandled stack trace, not a clean
`INVALID_RESPONSE` / exit 1, contradicting the "graceful-degradation" theme that the
prior reviews (CLI-002, CLI-003, CLI-005) repeatedly hardened.
**Recommendation**
Wrap the parse + base64-decode in a `try` block that catches `JsonException`,
`KeyNotFoundException`, `InvalidOperationException`, and `FormatException` and emits a
clean `OutputFormatter.WriteError(..., "INVALID_RESPONSE")` + `return 1`. Add a
regression test against a malformed-envelope stub `HttpMessageHandler`.
**Resolution**
_Unresolved._
### CLI-021 — `CliConfig.Load` crashes the CLI on a malformed config file
| | |
|--|--|
| Severity | Low |
| Category | Error handling & resilience |
| Status | Open |
| Location | `src/ScadaLink.CLI/CliConfig.cs:41-53` |
**Description**
`CliConfig.Load` is the first thing every command runs (via `ExecuteCommandAsync`,
`AuditCommandHelpers.ResolveConnection`, and `BundleCommands.RunBundleCommandAsync`).
Its config-file branch is:
```csharp
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
var fileConfig = JsonSerializer.Deserialize<CliConfigFile>(json, ...);
...
}
```
Neither call is guarded. If `~/.scadalink/config.json` exists but is malformed
(stale, partial, or someone's `vim` swap), `JsonSerializer.Deserialize` throws
`JsonException`. If the file exists but isn't readable (mode 0000),
`File.ReadAllText` throws `UnauthorizedAccessException`. Either fault aborts every
CLI invocation with an unhandled stack trace — even invocations that supply every
input on the command line and don't need the config file at all (`--url`,
`--username`, `--password`, `--format` all on the CLI).
**Recommendation**
Wrap the file-read and the `JsonSerializer.Deserialize` in a single
`try/catch (Exception)` (or specifically `JsonException` +
`UnauthorizedAccessException` + `IOException`). On failure, write a single one-line
warning to `Console.Error` ("ignoring malformed `~/.scadalink/config.json`: {message}")
and return the default `CliConfig`, so the rest of the precedence chain (env vars +
command-line flags) still works.
**Resolution**
_Unresolved._
### CLI-022 — `CommandTreeTests` excludes the two new command groups
| | |
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Location | `tests/ScadaLink.CLI.Tests/CommandTreeTests.cs:21-37`, `:55-58` (vs. `src/ScadaLink.CLI/Program.cs:21-36`) |
**Description**
`CommandTreeTests.AllCommandGroups()` builds 14 command groups; `Program.cs` now
registers 16 (`AuditCommands` and `BundleCommands` were added since the last
re-review). Worse, the smoke test pins `Assert.Equal(14, groups.Count)`, so the
test list intentionally matches the harness's array and stays green even though the
real production tree is two groups larger. The downstream assertions
(`EveryLeafCommand_HasAnAction`, `CommandPayloadTypes_ResolveViaRegistry`) therefore
also do NOT cover the new audit / bundle leaves — and `BundleCommands` has zero
test coverage of any kind (no parsing tests, no success-handler tests, no
registry-resolution tests).
**Recommendation**
Add `AuditCommands.Build(...)` and `BundleCommands.Build(...)` to the
`AllCommandGroups()` array, bump the assertion to `Equal(16, groups.Count)`, and add
representative payload types to `CommandPayloadTypes_ResolveViaRegistry`
(`ExportBundleCommand`, `PreviewBundleCommand`, `ImportBundleCommand`). Optionally,
add a `BundleCommandsTests` file covering the success-envelope parse and the
`NameListOption` comma-split parser.
**Resolution**
_Unresolved._
### CLI-023 — `Component-CLI.md` claims audit commands ride `POST /management`; implementation uses REST endpoints
| | |
|--|--|
| Severity | Low |
| Category | Design-document adherence |
| Status | Open |
| Location | `docs/requirements/Component-CLI.md:310-311` (vs. `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs:186`, `src/ScadaLink.CLI/Commands/AuditExportHelpers.cs:126`, `src/ScadaLink.CLI/ManagementHttpClient.cs:94-156`) |
**Description**
`Component-CLI.md:310` states: "The `scadalink audit` command group rides this same
transport — there is no separate audit endpoint." But the implementation calls a
new REST surface — `GET /api/audit/query` and `GET /api/audit/export` — via two new
methods on `ManagementHttpClient` (`SendGetAsync`, `SendGetStreamAsync`), distinct
from the `POST /management` envelope. The plan document
(`docs/plans/2026-05-20-audit-log-code-roadmap.md:1583`) corroborates the
implementation: "REST endpoints `GET /api/audit/query` (paged) and
`GET /api/audit/export` (streaming)" — i.e. the design doc is the stale one.
A reader following `Component-CLI.md` would expect the audit endpoints to share
the management envelope's authentication + dispatch path and route through
`ManagementActor`, neither of which is true. The auth-exit-code regression
(CLI-018) is itself a direct consequence of this divergence: the audit helpers
duplicate the management envelope's response handling instead of riding it, and
forgot to copy the auth carve-out.
**Recommendation**
Update `Component-CLI.md:310-311` (and the Dependencies bullet at `:311`) to
describe the actual REST surface: `GET /api/audit/query` (paged) and
`GET /api/audit/export` (streaming), with HTTP Basic Auth shared with the
management envelope and permission checks enforced by the server-side
`AuditController`. Optionally cross-link to
`docs/plans/2026-05-20-audit-log-code-roadmap.md` (M8 task list) as the
authoritative source.
**Resolution**
_Unresolved._