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:
@@ -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._
|
||||
|
||||
Reference in New Issue
Block a user