Files
scadalink-design/code-reviews/CLI/findings.md
Joseph Doherty 977d7369a7 docs: add code review process and baseline review of all 19 modules
Establishes a per-module code review workflow under code-reviews/ and
records the 2026-05-16 baseline review (commit 9c60592): 241 findings
across all src/ modules (6 Critical, 46 High, 100 Medium, 89 Low).
This is the clean starting point for remediation work.
2026-05-16 18:09:09 -04:00

19 KiB

Code Review — CLI

Field Value
Module src/ScadaLink.CLI
Design doc docs/requirements/Component-CLI.md
Status Reviewed
Last reviewed 2026-05-16
Reviewer claude-agent
Commit reviewed 9c60592
Open findings 13

Summary

The CLI is a small, well-structured HTTP client over the Management API. The command-tree construction is consistent and repetitive in a good way: every subcommand funnels through CommandHelpers.ExecuteCommandAsync, which centralizes URL/credential resolution, HTTP dispatch, and response handling. There are no Akka.NET concerns (the CLI is a pure HTTP client) and no concurrency-sensitive code apart from the debug stream SignalR handler.

The dominant theme is graceful-degradation gaps: several user-supplied inputs (malformed URLs, malformed --bindings/--overrides JSON, non-JSON success bodies) are deserialized or constructed without try/catch, so a normal user mistake surfaces as an unhandled exception with a stack trace instead of a clean error message and exit code 1. A second theme is dead configuration: the SCADALINK_FORMAT environment variable and the defaultFormat config-file field are loaded by CliConfig but never consulted by any command, so the documented format-precedence chain does not work. The third theme is substantial design-document drift: Component-CLI.md describes a name-keyed, --file-based command surface that bears little resemblance to the implemented ID-keyed, flag-based surface. Test coverage exercises OutputFormatter, CliConfig, and CommandHelpers.HandleResponse, but the HTTP client, the debug stream path, the JSON argument parsing, and the command-tree wiring are untested.

Checklist coverage

# Category Examined Notes
1 Correctness & logic bugs Format precedence is broken (CLI-001); empty/non-JSON success bodies crash table rendering (CLI-002, CLI-003).
2 Akka.NET conventions Not applicable — CLI is a pure HTTP/SignalR client with no Akka.NET runtime (design doc confirms). No issues.
3 Concurrency & thread safety Only debug stream is concurrent; CancellationTokenSource is never disposed (CLI-011). Exit-code resolution after Ctrl+C is loose (CLI-012).
4 Error handling & resilience Unhandled exceptions on malformed URL (CLI-004) and malformed JSON arguments (CLI-005); StartAsync cancellation is misreported (CLI-010).
5 Security --password on the command line leaks into process listings / shell history with no env-var or prompt alternative (CLI-006).
6 Performance & resource management HttpClient per invocation is acceptable for a one-shot CLI. CancellationTokenSource leak noted in CLI-011.
7 Design-document adherence Component-CLI.md is heavily stale relative to the implemented command surface (CLI-007).
8 Code organization & conventions Consistent and clean; CliConfig.DefaultFormat is loaded but unused (covered by CLI-001). Minor: --format not validated (CLI-008).
9 Testing coverage No tests for ManagementHttpClient, DebugCommands, command-tree wiring, or JSON argument parsing (CLI-013).
10 Documentation & comments Component-CLI.md mismatch (CLI-007); the in-repo README.md is reasonably accurate. Minor exit-code doc mismatch (CLI-009).

Findings

Severity High
Category Correctness & logic bugs
Status Open
Location src/ScadaLink.CLI/Commands/CommandHelpers.cs:18, src/ScadaLink.CLI/Commands/DebugCommands.cs:45, src/ScadaLink.CLI/CliConfig.cs:37-39

Description

CliConfig.Load() reads SCADALINK_FORMAT and the defaultFormat config-file field into CliConfig.DefaultFormat, and Component-CLI.md documents a format-precedence chain (command-line option → env var → config file). However, every command resolves the format with var format = result.GetValue(formatOption) ?? "json"; and formatOption is created in Program.cs:11 with DefaultValueFactory = _ => "json". GetValue therefore always returns a non-null value ("json" when the flag is absent), so the ?? "json" fallback never fires and config.DefaultFormat is never consulted. The env var and config-file format settings are dead code: scadalink site list always outputs JSON regardless of SCADALINK_FORMAT=table or a defaultFormat entry in ~/.scadalink/config.json. The documented behaviour silently does not work.

Recommendation

Either remove the --format option's DefaultValueFactory and have CommandHelpers resolve precedence explicitly (result.GetValue(formatOption)config.DefaultFormat), or detect whether the option was explicitly supplied (result.GetResult(formatOption)) and only then override the config value. Apply the same fix to DebugCommands.BuildStream.

Resolution

Unresolved.

CLI-002 — Empty success body crashes table rendering with an unhandled exception

Severity Medium
Category Correctness & logic bugs
Status Open
Location src/ScadaLink.CLI/Commands/CommandHelpers.cs:59-68, src/ScadaLink.CLI/Commands/CommandHelpers.cs:78-80

Description

ManagementHttpClient.SendCommandAsync returns JsonData = responseBody for any success status code, including a 200/204 with an empty body. HandleResponse then tests response.JsonData != null — an empty string is non-null — and for --format table calls WriteAsTable(response.JsonData), which immediately does JsonDocument.Parse(json). JsonDocument.Parse("") throws JsonException, which is not caught anywhere, so a command that legitimately returns no body (e.g. a delete that returns 204) terminates with a stack trace instead of a clean success message.

Recommendation

In HandleResponse, treat a null-or-whitespace JsonData as a "command succeeded, no output" case (print nothing or (ok)), and return 0 before attempting to parse.

Resolution

Unresolved.

CLI-003 — Non-JSON success body crashes table rendering

Severity Medium
Category Error handling & resilience
Status Open
Location src/ScadaLink.CLI/Commands/CommandHelpers.cs:80

Description

WriteAsTable calls JsonDocument.Parse(json) with no try/catch. If the server returns a success status but a body that is not valid JSON (a proxy/HTML error page returned with a 200, a plain-text message, etc.), the CLI throws an unhandled JsonException. The error-path code in ManagementHttpClient (lines 52-61) already defensively wraps JsonDocument.Parse in a try/catch; the success path and WriteAsTable do not get the same treatment.

Recommendation

Wrap the JsonDocument.Parse in WriteAsTable in a try/catch; on failure, fall back to printing the raw body verbatim (as the JSON path already does at line 66).

Resolution

Unresolved.

CLI-004 — Malformed --url throws an unhandled UriFormatException

Severity Medium
Category Error handling & resilience
Status Open
Location src/ScadaLink.CLI/ManagementHttpClient.cs:13

Description

The ManagementHttpClient constructor does new Uri(baseUrl.TrimEnd('/') + "/") with no validation. If the user passes a malformed URL (e.g. --url localhost:9001 without a scheme, or --url ""), new Uri(...) throws UriFormatException. This call is not guarded by the try/catch in SendCommandAsync (it happens in the constructor at CommandHelpers.cs:50), so a common typo terminates the CLI with a stack trace rather than the documented "connection failure → exit 1 with a descriptive message".

Recommendation

Validate the URL before constructing the client — e.g. Uri.TryCreate(url, UriKind.Absolute, out _) in CommandHelpers.ExecuteCommandAsync and DebugCommands.BuildStream — and emit a clean INVALID_URL error with exit code 1 on failure.

Resolution

Unresolved.

CLI-005 — Malformed --bindings / --overrides JSON throws unhandled exceptions

Severity Medium
Category Error handling & resilience
Status Open
Location src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58, src/ScadaLink.CLI/Commands/InstanceCommands.cs:181-182

Description

set-bindings deserializes the --bindings argument with JsonSerializer.Deserialize<List<List<JsonElement>>>(...) and then indexes p[0]/p[1] and calls p[0].GetString()! / p[1].GetInt32(). set-overrides deserializes --overrides with JsonSerializer.Deserialize<Dictionary<string, string?>>(...). None of this is wrapped in a try/catch. Invalid JSON throws JsonException; a pair with fewer than two elements throws ArgumentOutOfRangeException; a non-string/non-int element throws InvalidOperationException. All of these surface as raw stack traces, so a user typo in a JSON argument crashes the CLI instead of producing a clean validation error and exit code 1.

Recommendation

Wrap the parsing in try/catch (JsonException ...) (and guard the pair length / element kinds), and on failure call OutputFormatter.WriteError(...) with an INVALID_ARGUMENT code and return 1.

Resolution

Unresolved.

CLI-006 — Password is passed as a command-line argument with no safer alternative

Severity Medium
Category Security
Status Open
Location src/ScadaLink.CLI/Program.cs:9, src/ScadaLink.CLI/Commands/CommandHelpers.cs:36-44

Description

Credentials are supplied only via --username / --password. A password on the command line is visible to any local user via the process list (ps, /proc/<pid>/cmdline) and is typically persisted into shell history. Unlike the management URL — which can also come from SCADALINK_MANAGEMENT_URL or the config file — there is no environment-variable fallback, no --password-stdin, and no interactive prompt for the password. For a tool explicitly intended for CI/CD automation this materially increases the chance of credential leakage.

Recommendation

Add a SCADALINK_PASSWORD environment variable fallback and/or a --password-stdin option (read the password from stdin), and document that --password on the command line is discouraged. Optionally prompt interactively when stdin is a TTY and no password was supplied.

Resolution

Unresolved.

CLI-007 — Component-CLI.md command surface is substantially stale

Severity Medium
Category Design-document adherence
Status Open
Location docs/requirements/Component-CLI.md:51-211 (vs. all files under src/ScadaLink.CLI/Commands/)

Description

The "Command Structure" section of the design doc no longer matches the implemented CLI. Examples of the drift:

  • The doc keys most operations by name (template get <name>, instance get <code>, site get <site-id>); the implementation keys everything by integer ID via --id (TemplateCommands.cs:40, InstanceCommands.cs:31, SiteCommands.cs:26).
  • The doc shows template create ... --file <path> and site update <site-id> --file <path>; the implementation has no --file option anywhere and instead takes individual flags (TemplateCommands.cs:52-72, SiteCommands.cs:83-115).
  • The doc lists commands that do not exist (template diff, instance bind-connections, instance assign-area, template attribute add --tag-path, data-connection assign/unassign, security api-key enable/disable as separate commands) and omits commands that do exist (instance alarm-override set/delete/list, external-system method subgroup).
  • The doc's notification smtp update --file differs from the implemented --server/--port/--auth-mode/--from-address flags (NotificationCommands.cs:72-94).
  • The doc uses --site for site identification in several places where the implementation uses --site-id or --identifier.

A reader following the design doc would be unable to drive the CLI.

Recommendation

Regenerate the "Command Structure" section of Component-CLI.md from the actual command tree (the in-repo src/ScadaLink.CLI/README.md is much closer to reality and could be the source), or mark the doc's command list as illustrative and point to the README as authoritative.

Resolution

Unresolved.

CLI-008 — --format value is not validated

Severity Low
Category Code organization & conventions
Status Open
Location src/ScadaLink.CLI/Program.cs:10-11, src/ScadaLink.CLI/Commands/CommandHelpers.cs:60

Description

The --format option accepts any string. HandleResponse only checks string.Equals(format, "table", ...); any other value — including a typo like --format tabel or --format xml — silently falls through to JSON output. The user gets no feedback that their requested format was not honoured.

Recommendation

Restrict the option to the accepted values, e.g. formatOption.AcceptOnlyFromAmong("json", "table"), so System.CommandLine rejects invalid input with a clear parse error.

Resolution

Unresolved.

CLI-009 — Exit-code documentation does not match HandleResponse behaviour

Severity Low
Category Documentation & comments
Status Open
Location docs/requirements/Component-CLI.md:238-249, src/ScadaLink.CLI/Commands/CommandHelpers.cs:75

Description

The design doc's Exit Codes table defines code 2 as "Authorization failure (insufficient role)" and the Error Handling section says "If the server returns HTTP 403, the CLI exits with code 2." HandleResponse implements return response.StatusCode == 403 ? 2 : 1;, which is correct for the HTTP error path. However, the NO_URL, NO_CREDENTIALS, INVALID_OPERATION (from set-bindings/set-overrides) and any other client-side failure all return 1, and a connection failure carries StatusCode == 0 — none of which the doc enumerates. More importantly, an authorization failure that the server signals with a body code of UNAUTHORIZED but an HTTP status other than 403 would be classified as a generic error (exit 1). The mapping is purely status-driven and the doc does not state that.

Recommendation

Either document precisely that exit code 2 is determined solely by HTTP 403, or key the "authorization failure" exit code off the response code field as well. Align the doc with whichever is chosen.

Resolution

Unresolved.

CLI-010 — debug stream reports Ctrl+C during connect as a connection failure

Severity Low
Category Error handling & resilience
Status Open
Location src/ScadaLink.CLI/Commands/DebugCommands.cs:181-189

Description

StreamDebugAsync calls await connection.StartAsync(cts.Token) inside a try { } catch (Exception ex) that unconditionally reports "Connection failed: {ex.Message}" with code CONNECTION_FAILED and returns 1. If the user presses Ctrl+C while the connection is still being established, cts is cancelled and StartAsync throws OperationCanceledException; this is caught by the generic handler and misreported as a connection failure (with exit code 1) rather than a clean user-initiated cancellation (exit code 0).

Recommendation

Catch OperationCanceledException separately (return 0 quietly) before the generic catch (Exception) handler, mirroring how the exitTcs.Task.WaitAsync(cts.Token) path at lines 209-215 already treats cancellation as graceful.

Resolution

Unresolved.

CLI-011 — CancellationTokenSource in debug stream is never disposed

Severity Low
Category Performance & resource management
Status Open
Location src/ScadaLink.CLI/Commands/DebugCommands.cs:89

Description

var cts = new CancellationTokenSource(); is created in StreamDebugAsync but never disposed; there is no using declaration and no explicit Dispose() call on any exit path. CancellationTokenSource owns a WaitHandle and should be disposed. The impact is small because the process exits shortly after, but it is an IDisposable left undisposed, contrary to the review checklist's resource-management expectation.

Recommendation

Declare it as using var cts = new CancellationTokenSource(); (or wrap the method body in a try/finally).

Resolution

Unresolved.

CLI-012 — debug stream exit code is unreliable after stream termination

Severity Low
Category Concurrency & thread safety
Status Open
Location src/ScadaLink.CLI/Commands/DebugCommands.cs:208-227

Description

After await exitTcs.Task.WaitAsync(cts.Token), the method returns exitTcs.Task.IsCompletedSuccessfully ? exitTcs.Task.Result : 0. When the user cancels with Ctrl+C, WaitAsync throws OperationCanceledException and exitTcs is typically still incomplete, so the method returns 0 — correct. However, the OnStreamTerminated handler and the Closed handler both call exitTcs.TrySetResult, and these run on SignalR callback threads concurrently with the Ctrl+C path. If a stream termination and a Ctrl+C race, the final exit code depends on which TrySetResult won and whether WaitAsync observed completion before cancellation — the result is not deterministic. A stream the server terminated abnormally can end up returning 0.

Recommendation

Resolve the exit code from a single authoritative source: after the try/catch around WaitAsync, check exitTcs.Task completion explicitly and treat a Ctrl+C with no prior result as 0, but always prefer a result that was set by OnStreamTerminated/Closed. Consider awaiting exitTcs.Task without the cancellation token after a brief grace period.

Resolution

Unresolved.

CLI-013 — HTTP client, debug stream, and JSON-argument parsing are untested

Severity Low
Category Testing coverage
Status Open
Location tests/ScadaLink.CLI.Tests/ (vs. src/ScadaLink.CLI/ManagementHttpClient.cs, src/ScadaLink.CLI/Commands/DebugCommands.cs, src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58)

Description

The test project covers OutputFormatter, CliConfig.Load, and CommandHelpers.HandleResponse. It does not cover:

  • ManagementHttpClient.SendCommandAsync — the timeout (504), connection-failure (code 0), and error-body-parsing paths are untested.
  • The debug stream SignalR command — no tests at all.
  • The JSON-argument parsing in InstanceCommands (set-bindings, set-overrides) — the paths most likely to crash on bad input (CLI-005) have no coverage.
  • Command-tree wiring — there is no test asserting that each Build produces the expected subcommands/options or that the command-name derivation (ManagementCommandRegistry.GetCommandName) resolves for every command type the CLI constructs.

Recommendation

Add tests for ManagementHttpClient (using a stub HttpMessageHandler), for the JSON-argument parsing helpers (extracting the parsing into testable methods), and a smoke test that walks the root command tree and asserts every leaf command's payload type resolves via ManagementCommandRegistry.

Resolution

Unresolved.