fix(CLI-45): standardize the CLI credential env var and fail fast on empty passwords

All five client CLIs now share one credential contract for `authenticate-user`:
flags `--password` / `--password-env` (Go: `-password` / `-password-env`) with
default env `MXGATEWAY_VERIFY_PASSWORD`, resolution flag-then-env, and a resolved
credential that is missing *or empty* is a usage error naming the flag and the
variable. The value is never echoed and never reaches the wire.

Go and Java previously sent an empty credential when the variable was unset,
turning a misconfigured environment into a real MXAccess authentication attempt.
Go now returns the guard error before dialing; Java throws a picocli
ParameterException instead of falling back to "". Python's `--password-env`
gained the canonical default and its UsageError names the resolved variable.
Rust treats an empty flag or env value as missing, with the resolution extracted
into a testable `resolve_verify_user_password`. .NET adopts the canonical flags
and keeps `--verify-user-password`, `--verify-user-password-env`, and
MXGATEWAY_VERIFY_USER_PASSWORD as deprecated aliases for one release.

Docs same commit: CrossLanguageSmokeMatrix.md gains the credential contract and
the per-CLI subcommand-coverage table (the documented-not-fixed half of the
finding); all five READMEs name the canonical variable and the fail-fast rule,
and the .NET README carries the deprecation note. Tracking flipped to Done in
both remediation registers with a change-log row.

No .proto changed; no generated code regenerated.
This commit is contained in:
Joseph Doherty
2026-08-07 06:03:24 -04:00
parent cf66ebbcfb
commit 37cb3b0df8
17 changed files with 764 additions and 53 deletions
+7 -2
View File
@@ -167,8 +167,13 @@ session.write(serverHandle, itemHandle, value, userId);
native failure is surfaced, not papered over.
The CLI exposes `advise-supervisory`, `write-secured`, and `authenticate-user`
(credential via `--password` or `--password-env`, never echoed), and `write` /
`write2` take `--user-id`.
(credential via `--password` or the variable named by `--password-env`, default
`MXGATEWAY_VERIFY_PASSWORD`, never echoed), and `write` / `write2` take
`--user-id`. The credential is required: a missing or empty resolved value is a
picocli usage error naming the option and the variable, so the CLI fails before
connecting instead of authenticating with an empty password.
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client
CLIs — see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
### Array writes replace the whole array
@@ -70,6 +70,7 @@ import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Spec;
/**
@@ -182,6 +183,13 @@ public final class MxGatewayCli implements Callable<Integer> {
/** Sentinel written to stdout after every command result in batch mode. */
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
/**
* Canonical CLI credential environment variable, shared by every official
* client CLI (CLI-45) so one exported variable drives the same operator
* workflow in all five languages.
*/
static final String DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD";
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
private static final Object ALARM_FEED_END = new Object();
@@ -1139,7 +1147,7 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(
names = "--password-env",
defaultValue = "MXGATEWAY_VERIFY_PASSWORD",
defaultValue = DEFAULT_VERIFY_PASSWORD_ENV,
description = "Environment variable holding the password when --password is omitted.")
String passwordEnv;
@@ -1151,11 +1159,20 @@ public final class MxGatewayCli implements Callable<Integer> {
public Integer call() {
// Resolve the credential from the flag or environment. It flows only
// into the request; it is never written to output, logs, or errors.
String environmentName =
passwordEnv == null || passwordEnv.isBlank() ? DEFAULT_VERIFY_PASSWORD_ENV : passwordEnv;
String resolvedPassword = password == null || password.isBlank()
? System.getenv(passwordEnv)
? System.getenv(environmentName)
: password;
if (resolvedPassword == null) {
resolvedPassword = "";
if (resolvedPassword == null || resolvedPassword.isBlank()) {
// Fail fast instead of dialing: a misconfigured environment must not
// become a real MXAccess authentication attempt with an empty
// credential (CLI-45). The message names the option and the variable
// only — never the value.
throw new ParameterException(
common.spec.commandLine(),
"a password is required via --password or the " + environmentName
+ " environment variable");
}
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
int userId = client.session(sessionId)
@@ -2,7 +2,10 @@ package com.zb.mom.ww.mxgateway.cli;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
@@ -211,6 +214,73 @@ final class MxGatewayCliTests {
assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
}
/**
* CLI-45: an unresolved credential must abort with a picocli usage error
* before the CLI dials, instead of authenticating with an empty password.
* The message names the option and the variable, never a value.
*/
@Test
void authenticateUserRejectsMissingCredentialWithUsageError() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
"--json");
assertNotEquals(0, run.exitCode(), "a missing credential must fail");
assertTrue(run.errors().contains("--password"), run.errors());
assertTrue(run.errors().contains("MXGW_CLI45_ABSENT_PASSWORD_VAR"), run.errors());
assertNull(factory.client, "the CLI must not connect without a credential");
}
/**
* CLI-45: a blank {@code --password} is treated as missing — the CLI never
* sends a fabricated empty credential to the wire.
*/
@Test
void authenticateUserRejectsBlankPasswordValue() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--password", "",
"--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
"--json");
assertNotEquals(0, run.exitCode(), "a blank credential must fail");
assertNull(factory.client, "the CLI must not connect without a credential");
}
/**
* CLI-45: {@code --password-env} defaults to the canonical
* {@code MXGATEWAY_VERIFY_PASSWORD}, so the usage error names it when no
* explicit variable is given. Skipped if the canonical variable happens to be
* exported in the running environment (which would satisfy the credential).
*/
@Test
void authenticateUserDefaultsToCanonicalPasswordEnvName() {
assumeTrue(System.getenv(MxGatewayCli.DEFAULT_VERIFY_PASSWORD_ENV) == null);
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--json");
assertNotEquals(0, run.exitCode());
assertTrue(run.errors().contains("MXGATEWAY_VERIFY_PASSWORD"), run.errors());
}
// ---- ping subcommand (D4) ----
@Test