fix(CLI-40,CLI-41,CLI-44): exact-secret scrub, uniform malformed-reply contract, Go terminal-error mislabel

CLI-40: port the exact-secret credential scrub to Rust/Java/.NET (Go/Python
already did it). AuthenticateUser/WriteSecured(2) helpers now redact the exact
caller-supplied secret from any surfaced error, as defense-in-depth on top of the
by-construction guarantee. Rust hand-writes a redacting Debug (derived Debug would
leak the reply); Java/.NET rebuild the same exception type with the redacted
message and do not carry the secret-bearing original forward (so ToString/stack
traces stay clean too).

CLI-41: uniform malformed-reply contract for AuthenticateUser/ArchestrAUserToId/
AddBufferedItem across all five clients — typed payload, else a present int32
return_value, else a typed malformed-reply error. Fixes Go/Java silent-0, .NET
NRE, and Rust's own internal inconsistency.

CLI-44: the Go event goroutine's Recv-error path now uses a non-blocking
sendTerminalEventResult on the reserved slot, so a genuine terminal stream error
is reported as itself instead of being mislabeled ErrSlowConsumer under overflow.

Riders from the CLI-37/38 review: (a) .NET ToDiagnosticSummary and Python
_mxaccess_message surface the raw success member (diagnostics-only parity with
Rust); (b) the status-conversion fixture carries an independent wantSuccess
boolean and the Go/.NET fixture tests assert against it instead of recomputing
the formula under test.

Shared fixtures (authenticate-user.{echoed-credential,missing-payload,
return-value-only}.reply.json) + manifest + ClientBehaviorFixtures.md +
ClientLibrariesDesign.md updated in the same change. Tracking: CLI-40/41/44 -> Done.
This commit is contained in:
Joseph Doherty
2026-08-07 06:42:40 -04:00
parent d2bb32d97b
commit dc7fd16dd5
33 changed files with 1465 additions and 97 deletions
+48 -10
View File
@@ -27,7 +27,7 @@ use crate::generated::mxaccess_gateway::v1::{
WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
use crate::value::{MxStatus, MxValue};
use crate::value::{MxStatus, MxValue, MxValueProjection};
const MAX_BULK_ITEMS: usize = 1_000;
@@ -801,6 +801,7 @@ impl Session {
verifier_user_id: i32,
value: MxValue,
) -> Result<(), Error> {
let secrets = string_secret(&value);
self.invoke(
MxCommandKind::WriteSecured,
Payload::WriteSecured(WriteSecuredCommand {
@@ -811,7 +812,8 @@ impl Session {
value: Some(value.into_proto()),
}),
)
.await?;
.await
.map_err(|error| attach_secrets(error, secrets))?;
Ok(())
}
@@ -831,6 +833,7 @@ impl Session {
value: MxValue,
timestamp_value: MxValue,
) -> Result<(), Error> {
let secrets = string_secret(&value);
self.invoke(
MxCommandKind::WriteSecured2,
Payload::WriteSecured2(WriteSecured2Command {
@@ -842,7 +845,8 @@ impl Session {
timestamp_value: Some(timestamp_value.into_proto()),
}),
)
.await?;
.await
.map_err(|error| attach_secrets(error, secrets))?;
Ok(())
}
@@ -882,7 +886,8 @@ impl Session {
verify_user_password: verify_user_password.to_owned(),
}),
)
.await?;
.await
.map_err(|error| attach_secrets(error, vec![verify_user_password.to_owned()]))?;
authenticate_user_id(&reply)
}
@@ -1074,18 +1079,51 @@ fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
fn authenticate_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
_ => Err(Error::MalformedReply {
detail: "authenticate_user reply lacked an AuthenticateUser payload".to_owned(),
}),
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"authenticate_user reply lacked an AuthenticateUser payload or int32 return_value"
.to_owned(),
}),
}
}
fn archestra_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
_ => Err(Error::MalformedReply {
detail: "archestra_user_to_id reply lacked an ArchestraUserToId payload".to_owned(),
}),
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"archestra_user_to_id reply lacked an ArchestraUserToId payload or int32 return_value"
.to_owned(),
}),
}
}
/// Extract an exact string secret from a credential-sensitive [`MxValue`] so a
/// failing `WriteSecured`/`WriteSecured2` can scrub it from the surfaced error.
/// Non-string values carry no scrubbable secret and yield an empty vector.
fn string_secret(value: &MxValue) -> Vec<String> {
match value.projection() {
MxValueProjection::String(text) if !text.is_empty() => vec![text.clone()],
_ => Vec::new(),
}
}
/// Attach caller-supplied exact secrets to an [`Error::MxAccess`] before it
/// propagates, so its `Display` scrubs any occurrence of the credential (e.g. a
/// password MXAccess echoed back verbatim). Any other error variant is returned
/// unchanged.
fn attach_secrets(error: Error, secrets: Vec<String>) -> Error {
match error {
Error::MxAccess(boxed) => Error::MxAccess(Box::new(boxed.with_secrets(secrets))),
other => other,
}
}