Merge branch 'fix/cli-45-credential-envvar'
ci / java (push) Successful in 2m51s
ci / windows-x86 (push) Successful in 1m21s
ci / nightly-windev (push) Has been skipped
ci / portable (push) Successful in 9m41s

# Conflicts:
#	archreview/2026-07-12/remediation/00-tracking.md
This commit is contained in:
Joseph Doherty
2026-08-07 06:09:03 -04:00
26 changed files with 773 additions and 53 deletions
+6 -1
View File
@@ -216,7 +216,12 @@ the wire — the client never logs them and never embeds them in an `Error`'s
`Display`/`Debug`; the only error text that can surface (from `tonic::Status`
messages and reply diagnostics) is scrubbed by the credential-redaction seam.
The CLI mirrors these as `authenticate-user` (password via `--password` or the
`--password-env` env var, never echoed) and `write-secured`.
variable named by `--password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, never
echoed) and `write-secured`. The credential is required: a missing or empty
resolved value is a usage error naming the flag and the variable, so the CLI
fails before dialing 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).
The remaining single-item command helpers round out MXAccess parity:
`unregister`, `suspend` / `activate` (each returns the operation's
+86 -11
View File
@@ -752,17 +752,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
password_env,
json,
} => {
// Resolve the credential from --password or the named env var.
// The password is passed straight to the typed helper and is never
// echoed to stdout/stderr or embedded in an error message.
let verify_user_password = password
.or_else(|| env::var(&password_env).ok())
.ok_or_else(|| Error::InvalidArgument {
name: "password".to_owned(),
detail: format!(
"supply --password or set the environment variable `{password_env}`"
),
})?;
let verify_user_password = resolve_verify_user_password(password, &password_env)?;
let session = session_for(connection, session_id).await?;
let user_id = session
.authenticate_user(server_handle, &verify_user, &verify_user_password)
@@ -1736,6 +1726,31 @@ fn print_ok(operation: &str, use_json: bool) {
}
}
/// Resolves the `authenticate-user` credential from `--password`, falling back to
/// the environment variable named by `--password-env` (default
/// `MXGATEWAY_VERIFY_PASSWORD`).
///
/// An empty value from either source counts as missing (CLI-45): the CLI fails
/// fast with a usage error rather than sending a fabricated empty credential to
/// the wire. The error names the flag and the variable only — never the value,
/// which is never echoed to stdout/stderr or embedded in an error message.
fn resolve_verify_user_password(
password: Option<String>,
password_env: &str,
) -> Result<String, Error> {
password
.filter(|value| !value.is_empty())
.or_else(|| {
env::var(password_env)
.ok()
.filter(|value| !value.is_empty())
})
.ok_or_else(|| Error::InvalidArgument {
name: "password".to_owned(),
detail: format!("supply --password or set the environment variable `{password_env}`"),
})
}
fn print_bulk_results(
operation: &str,
results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
@@ -2617,6 +2632,66 @@ mod tests {
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
/// CLI-45: `--password-env` must default to the canonical
/// `MXGATEWAY_VERIFY_PASSWORD` shared by every official client CLI.
#[test]
fn authenticate_user_password_env_defaults_to_canonical_name() {
let parsed = Cli::try_parse_from([
"mxgw",
"authenticate-user",
"--session-id",
"session-1",
"--server-handle",
"7",
"--verify-user",
"verifier",
])
.expect("parse");
match parsed.command {
Command::AuthenticateUser { password_env, .. } => {
assert_eq!(password_env, "MXGATEWAY_VERIFY_PASSWORD");
}
other => panic!("expected authenticate-user, got {other:?}"),
}
}
/// CLI-45: a credential that resolves to an empty string — whether from the
/// flag or from the named environment variable — is treated as missing, so
/// the CLI never sends a fabricated empty password to the wire. The usage
/// error names the flag and the variable, never a value.
#[test]
fn resolve_verify_user_password_rejects_missing_and_empty_values() {
const ABSENT: &str = "MXGW_CLI45_ABSENT_PASSWORD_VAR";
const EMPTY: &str = "MXGW_CLI45_EMPTY_PASSWORD_VAR";
const PRESENT: &str = "MXGW_CLI45_PRESENT_PASSWORD_VAR";
std::env::remove_var(ABSENT);
std::env::set_var(EMPTY, "");
std::env::set_var(PRESENT, "env-sourced-credential");
for (password, env_name) in [
(None, ABSENT),
(Some(String::new()), ABSENT),
(None, EMPTY),
(Some(String::new()), EMPTY),
] {
let error = super::resolve_verify_user_password(password, env_name)
.expect_err("empty or missing credential must be a usage error");
let rendered = error.to_string();
assert!(rendered.contains("--password"), "{rendered}");
assert!(rendered.contains(env_name), "{rendered}");
}
assert_eq!(
super::resolve_verify_user_password(None, PRESENT).expect("env credential"),
"env-sourced-credential"
);
assert_eq!(
super::resolve_verify_user_password(Some("flag-credential".to_owned()), EMPTY)
.expect("flag credential"),
"flag-credential"
);
}
#[test]
fn parses_write_secured_command() {
let parsed = Cli::try_parse_from([