dc7fd16dd5
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.
220 lines
7.0 KiB
Go
220 lines
7.0 KiB
Go
package mxgateway
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
|
)
|
|
|
|
// redactedSecretMarker is the placeholder substituted for credential material in
|
|
// surfaced error text. It matches the marker used by RedactAPIKey so the client
|
|
// presents one consistent redaction shape everywhere secrets could otherwise
|
|
// leak.
|
|
const redactedSecretMarker = "<redacted>"
|
|
|
|
// secretRedactingError wraps a typed error so any occurrence of a known
|
|
// credential in the underlying message is replaced with redactedSecretMarker in
|
|
// the surfaced text. Unwrap still exposes the wrapped error, so errors.As /
|
|
// errors.Is continue to reach the underlying MxAccessError, CommandError, or
|
|
// GatewayError. This is the seam that keeps AuthenticateUser credentials and
|
|
// WriteSecured/WriteSecured2 payload strings out of any error a caller might log,
|
|
// even if a gateway diagnostic message were to echo them back.
|
|
type secretRedactingError struct {
|
|
err error
|
|
secrets []string
|
|
}
|
|
|
|
// Error returns the wrapped error's message with every non-empty secret redacted.
|
|
func (e *secretRedactingError) Error() string {
|
|
if e == nil || e.err == nil {
|
|
return ""
|
|
}
|
|
message := e.err.Error()
|
|
for _, secret := range e.secrets {
|
|
if secret != "" {
|
|
message = strings.ReplaceAll(message, secret, redactedSecretMarker)
|
|
}
|
|
}
|
|
return message
|
|
}
|
|
|
|
// Unwrap returns the wrapped error so typed-error inspection still works through
|
|
// the redaction wrapper.
|
|
func (e *secretRedactingError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.err
|
|
}
|
|
|
|
// redactSecrets wraps err so any occurrence of a non-empty secret in the surfaced
|
|
// message is redacted, while errors.As / errors.Is still reach the wrapped typed
|
|
// error. It returns nil unchanged and skips wrapping when no non-empty secret is
|
|
// supplied, so non-secret-bearing calls keep their original error verbatim.
|
|
func redactSecrets(err error, secrets ...string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
for _, secret := range secrets {
|
|
if secret != "" {
|
|
return &secretRedactingError{err: err, secrets: secrets}
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
|
|
// (cancel-when-full) path when the buffered results channel overflows because
|
|
// the consumer fell behind. It is delivered as the final EventResult.Err before
|
|
// the channel closes, so overflow is always observable rather than silently
|
|
// dropping events. Match it with errors.Is.
|
|
var ErrSlowConsumer = errors.New("mxgateway: event consumer fell behind; stream terminated")
|
|
|
|
// MalformedReplyError reports an OK command reply that carried neither the
|
|
// typed payload the operation expected nor a usable int32 return_value, so the
|
|
// client cannot produce a result. It gives every affected helper one uniform,
|
|
// inspectable failure instead of silently returning a zero value.
|
|
type MalformedReplyError struct {
|
|
// Op names the operation whose reply was malformed.
|
|
Op string
|
|
// Detail explains what the reply was missing.
|
|
Detail string
|
|
}
|
|
|
|
// Error returns the formatted malformed-reply message.
|
|
func (e *MalformedReplyError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("mxgateway: %s returned a malformed reply: %s", e.Op, e.Detail)
|
|
}
|
|
|
|
// GatewayError wraps transport-level gRPC failures.
|
|
type GatewayError struct {
|
|
// Op names the operation that failed (for example "dial" or "invoke").
|
|
Op string
|
|
// Err is the underlying gRPC or transport error.
|
|
Err error
|
|
}
|
|
|
|
// Error returns the formatted gateway error message.
|
|
func (e *GatewayError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Op == "" {
|
|
return fmt.Sprintf("mxgateway: %v", e.Err)
|
|
}
|
|
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
|
|
}
|
|
|
|
// Unwrap returns the wrapped transport error.
|
|
func (e *GatewayError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.Err
|
|
}
|
|
|
|
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
|
// command reply when one exists.
|
|
type CommandError struct {
|
|
// Op names the gateway operation that produced the non-OK status.
|
|
Op string
|
|
// Status carries the gateway-reported protocol status.
|
|
Status *ProtocolStatus
|
|
// Reply is the raw command reply, when one was returned alongside the status.
|
|
Reply *MxCommandReply
|
|
}
|
|
|
|
// Error returns the formatted command error message.
|
|
func (e *CommandError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
status := e.Status
|
|
if status == nil {
|
|
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
|
|
}
|
|
if status.GetMessage() == "" {
|
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
|
|
}
|
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
|
|
}
|
|
|
|
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
|
|
type MxAccessError struct {
|
|
// Command is the wrapped CommandError when the protocol status carried one.
|
|
Command *CommandError
|
|
// Reply is the raw MXAccess command reply that surfaced the failure.
|
|
Reply *MxCommandReply
|
|
}
|
|
|
|
// Error returns the formatted MXAccess error message.
|
|
func (e *MxAccessError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
|
|
return e.Command.Error()
|
|
}
|
|
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
|
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
|
|
}
|
|
if e.Reply != nil && e.Reply.Hresult != nil {
|
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
|
|
}
|
|
return "mxgateway: MXAccess command failed"
|
|
}
|
|
|
|
// Unwrap returns the wrapped CommandError, when one is present.
|
|
func (e *MxAccessError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.Command
|
|
}
|
|
|
|
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
|
|
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
|
|
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
|
|
return nil
|
|
}
|
|
|
|
commandError := &CommandError{
|
|
Op: op,
|
|
Status: status,
|
|
Reply: reply,
|
|
}
|
|
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
|
|
return &MxAccessError{
|
|
Command: commandError,
|
|
Reply: reply,
|
|
}
|
|
}
|
|
return commandError
|
|
}
|
|
|
|
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
|
|
// MXSTATUS_PROXY entries.
|
|
//
|
|
// Following COM semantics, only a negative HRESULT is a failure — positive
|
|
// success codes such as S_FALSE (1) pass. Status entries are judged by
|
|
// StatusSucceeded, which branches on the authoritative category.
|
|
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
|
|
if reply == nil {
|
|
return nil
|
|
}
|
|
if reply.Hresult != nil && reply.GetHresult() < 0 {
|
|
return &MxAccessError{Reply: reply}
|
|
}
|
|
for _, status := range reply.GetStatuses() {
|
|
if !StatusSucceeded(status) {
|
|
return &MxAccessError{Reply: reply}
|
|
}
|
|
}
|
|
return nil
|
|
}
|