package mxgateway import ( "errors" "fmt" "strings" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" "google.golang.org/protobuf/proto" ) // 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 = "" // 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 } // scrubReplyStrings returns a clone of reply with every non-empty secret replaced // by redactedSecretMarker in the free-text fields a gateway diagnostic could echo a // credential into: ProtocolStatus.Message, DiagnosticMessage, and each // Statuses[].DiagnosticText. It clones with proto.Clone so the caller's original // reply is never mutated. A nil reply, or an empty/whitespace-only secret set, is a // no-op (nil in, nil out; a clone otherwise). func scrubReplyStrings(reply *pb.MxCommandReply, secrets []string) *pb.MxCommandReply { if reply == nil { return nil } clone, ok := proto.Clone(reply).(*pb.MxCommandReply) if !ok { return reply } for _, secret := range secrets { if secret == "" { continue } if clone.GetProtocolStatus() != nil { clone.ProtocolStatus.Message = strings.ReplaceAll(clone.GetProtocolStatus().GetMessage(), secret, redactedSecretMarker) } clone.DiagnosticMessage = strings.ReplaceAll(clone.GetDiagnosticMessage(), secret, redactedSecretMarker) for _, status := range clone.GetStatuses() { status.DiagnosticText = strings.ReplaceAll(status.GetDiagnosticText(), secret, redactedSecretMarker) } } return clone } // scrubProtocolStatusMessage returns a clone of status with every non-empty secret // redacted from its Message, leaving the original untouched. func scrubProtocolStatusMessage(status *ProtocolStatus, secrets []string) *ProtocolStatus { if status == nil { return nil } clone, ok := proto.Clone(status).(*ProtocolStatus) if !ok { return status } for _, secret := range secrets { if secret != "" { clone.Message = strings.ReplaceAll(clone.GetMessage(), secret, redactedSecretMarker) } } return clone } // redactSecrets scrubs a non-empty secret set from the error it surfaces. When the // wrapped error is a typed *MxAccessError or *CommandError it is rebuilt carrying // scrubbed clones of its reply and protocol status, so a caller logging the typed // error's structured fields cannot reintroduce the credential the rendered message // hides. The rebuilt (or original, for other error types) value is then wrapped in // secretRedactingError as a belt-and-suspenders scrub of any remaining rendered // text. errors.As / errors.Is still reach the typed error through the wrapper. It // returns nil unchanged and skips all work 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 } hasSecret := false for _, secret := range secrets { if secret != "" { hasSecret = true break } } if !hasSecret { return err } rebuilt := rebuildScrubbedError(err, secrets) return &secretRedactingError{err: rebuilt, secrets: secrets} } // rebuildScrubbedError rebuilds the typed error carrying scrubbed clones of any // command reply / protocol status it holds, so credential text never survives in the // error's structured fields. Non-reply-bearing error types are returned unchanged. func rebuildScrubbedError(err error, secrets []string) error { switch typed := err.(type) { case *MxAccessError: return &MxAccessError{ Command: scrubCommandError(typed.Command, secrets), Reply: scrubReplyStrings(typed.Reply, secrets), } case *CommandError: return scrubCommandError(typed, secrets) default: return err } } // scrubCommandError rebuilds a CommandError with a scrubbed Status and Reply. func scrubCommandError(cmd *CommandError, secrets []string) *CommandError { if cmd == nil { return nil } return &CommandError{ Op: cmd.Op, Status: scrubProtocolStatusMessage(cmd.Status, secrets), Reply: scrubReplyStrings(cmd.Reply, secrets), } } // 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 }