This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git


The following commit(s) were added to refs/heads/master by this push:
     new 9f77cf18 [ISSUE #1428] [Rust] Decompress received message body and 
verify its digest (#1429)
9f77cf18 is described below

commit 9f77cf18a8c049698d97f3a1268626debdf3f549
Author: Lei Zhiyuan <[email protected]>
AuthorDate: Fri Oct 23 22:20:19 2026 +0800

    [ISSUE #1428] [Rust] Decompress received message body and verify its digest 
(#1429)
---
 rust/Cargo.toml           |   3 +
 rust/src/model/message.rs | 242 +++++++++++++++++++++++++++++++++++++++++++++-
 rust/src/push_consumer.rs | 164 +++++++++++++++++++++++++++++++
 3 files changed, 408 insertions(+), 1 deletion(-)

diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index dc8ca8d7..14968994 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -62,6 +62,9 @@ mockall = "0.11.4"
 mockall_double = "0.3.0"
 
 siphasher = "0.3.10"
+flate2 = "1"
+crc32fast = "1"
+md-5 = "0.10"
 ring = "0.16.20"
 tokio-util = { version = "=0.7.10", features = ["rt"] }
 # Pin indexmap to avoid hashbrown 0.17+ which requires Rust 2024 edition
diff --git a/rust/src/model/message.rs b/rust/src/model/message.rs
index 5c58c2f6..b7aaf8a4 100644
--- a/rust/src/model/message.rs
+++ b/rust/src/model/message.rs
@@ -18,6 +18,9 @@
 //! Message data model of RocketMQ rust client.
 
 use std::collections::HashMap;
+use std::io::Read;
+
+use tracing::error;
 
 use crate::error::{ClientError, ErrorKind};
 use crate::model::common::Endpoints;
@@ -431,6 +434,91 @@ impl MessageBuilder {
     }
 }
 
+/// Verify the body digest and decode the body according to its encoding.
+///
+/// Returns the body to expose and whether the message is corrupted. The 
digest is computed by
+/// the server on the body as stored, so it is checked before decoding.
+///
+/// Reference Java: `MessageViewImpl#fromProtobuf`.
+fn decode_body(
+    body: Vec<u8>,
+    system_properties: &pb::SystemProperties,
+    topic: &str,
+    message_id: &str,
+) -> (Vec<u8>, bool) {
+    let mut corrupted = false;
+    if let Some(digest) = &system_properties.body_digest {
+        match pb::DigestType::from_i32(digest.r#type) {
+            Some(pb::DigestType::Crc32) => {
+                corrupted |= format!("{:X}", crc32fast::hash(&body)) != 
digest.checksum;
+            }
+            Some(pb::DigestType::Md5) => {
+                use md5::Digest;
+                corrupted |= hex::encode_upper(md5::Md5::digest(&body)) != 
digest.checksum;
+            }
+            Some(pb::DigestType::Sha1) => {
+                let sha1 = 
ring::digest::digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, &body);
+                corrupted |= hex::encode_upper(sha1.as_ref()) != 
digest.checksum;
+            }
+            _ => error!(
+                "Unsupported message body digest algorithm, digest_type={}, 
topic={}, message_id={}",
+                digest.r#type, topic, message_id
+            ),
+        }
+    }
+    if corrupted {
+        error!(
+            "Message body digest mismatch, topic={}, message_id={}",
+            topic, message_id
+        );
+    }
+    match pb::Encoding::from_i32(system_properties.body_encoding) {
+        Some(pb::Encoding::Gzip) => match decompress(&body) {
+            Ok(decompressed) => (decompressed, corrupted),
+            Err(e) => {
+                error!(
+                    "Failed to uncompress message body, topic={}, 
message_id={}, error={}",
+                    topic, message_id, e
+                );
+                (body, true)
+            }
+        },
+        Some(pb::Encoding::Identity) => (body, corrupted),
+        _ => {
+            error!(
+                "Unsupported message encoding algorithm, topic={}, 
message_id={}, body_encoding={}",
+                topic, message_id, system_properties.body_encoding
+            );
+            (body, corrupted)
+        }
+    }
+}
+
+/// Decompress a body by its magic number.
+///
+/// The proxy marks every body that was stored compressed as `GZIP`, whatever 
the algorithm. The
+/// remoting producer compresses with ZLIB by default. LZ4 and ZSTD are not 
supported.
+///
+/// Reference Java: `Utilities#decompressBytes`.
+fn decompress(body: &[u8]) -> std::io::Result<Vec<u8>> {
+    let mut decompressed = Vec::new();
+    match body {
+        [0x1f, 0x8b, ..] => {
+            flate2::read::MultiGzDecoder::new(body).read_to_end(&mut 
decompressed)?;
+        }
+        [0x78, ..] => {
+            flate2::read::ZlibDecoder::new(body).read_to_end(&mut 
decompressed)?;
+        }
+        _ => {
+            return Err(std::io::Error::new(
+                std::io::ErrorKind::InvalidData,
+                "unsupported compression format",
+            ))
+        }
+    }
+    Ok(decompressed)
+}
+
 /// [`AckMessageEntry`] is the data model for ack message.
 pub trait AckMessageEntry {
     fn topic(&self) -> String;
@@ -460,6 +548,7 @@ pub struct MessageView {
     pub(crate) endpoints: Endpoints,
     pub(crate) priority: Option<i32>,
     pub(crate) lite_topic: Option<String>,
+    pub(crate) corrupted: bool,
 }
 
 impl AckMessageEntry for MessageView {
@@ -484,12 +573,18 @@ impl MessageView {
     pub(crate) fn from_pb_message(message: pb::Message, endpoints: Endpoints) 
-> Option<Self> {
         let system_properties = message.system_properties?;
         let topic = message.topic?;
+        let (body, corrupted) = decode_body(
+            message.body,
+            &system_properties,
+            &topic.name,
+            &system_properties.message_id,
+        );
         Some(MessageView {
             message_id: system_properties.message_id,
             receipt_handle: system_properties.receipt_handle,
             namespace: topic.resource_namespace,
             topic: topic.name,
-            body: message.body,
+            body,
             tag: system_properties.tag,
             keys: system_properties.keys,
             properties: message.user_properties,
@@ -501,6 +596,7 @@ impl MessageView {
             endpoints,
             priority: system_properties.priority,
             lite_topic: system_properties.lite_topic,
+            corrupted,
         })
     }
 
@@ -524,6 +620,13 @@ impl MessageView {
         &self.body
     }
 
+    /// Whether the message body failed its digest check or could not be 
decompressed.
+    ///
+    /// A corrupted message keeps the body as received. Reference Java: 
`MessageViewImpl#isCorrupted`.
+    pub fn is_corrupted(&self) -> bool {
+        self.corrupted
+    }
+
     /// Get message tag
     pub fn tag(&self) -> Option<&str> {
         self.tag.as_deref()
@@ -587,6 +690,143 @@ impl MessageView {
 mod tests {
     use super::*;
 
+    mod body_decoding {
+        use std::io::Write;
+
+        use super::*;
+
+        const PLAIN: &[u8] = b"hello compressed body hello compressed body 
hello compressed body";
+
+        fn gzip(data: &[u8]) -> Vec<u8> {
+            let mut encoder =
+                flate2::write::GzEncoder::new(Vec::new(), 
flate2::Compression::default());
+            encoder.write_all(data).unwrap();
+            encoder.finish().unwrap()
+        }
+
+        fn zlib(data: &[u8]) -> Vec<u8> {
+            let mut encoder =
+                flate2::write::ZlibEncoder::new(Vec::new(), 
flate2::Compression::default());
+            encoder.write_all(data).unwrap();
+            encoder.finish().unwrap()
+        }
+
+        fn md5_hex(data: &[u8]) -> String {
+            use md5::Digest;
+            hex::encode_upper(md5::Md5::digest(data))
+        }
+
+        fn view(body: Vec<u8>, encoding: pb::Encoding, digest: 
Option<pb::Digest>) -> MessageView {
+            MessageView::from_pb_message(
+                pb::Message {
+                    topic: Some(pb::Resource {
+                        name: "topic".to_string(),
+                        resource_namespace: "".to_string(),
+                    }),
+                    user_properties: Default::default(),
+                    system_properties: Some(pb::SystemProperties {
+                        body_encoding: encoding as i32,
+                        body_digest: digest,
+                        ..Default::default()
+                    }),
+                    body,
+                },
+                Endpoints::from_url("localhost:8081").unwrap(),
+            )
+            .unwrap()
+        }
+
+        fn digest(r#type: pb::DigestType, checksum: String) -> 
Option<pb::Digest> {
+            Some(pb::Digest {
+                r#type: r#type as i32,
+                checksum,
+            })
+        }
+
+        #[test]
+        fn identity_body_is_kept() {
+            let message = view(PLAIN.to_vec(), pb::Encoding::Identity, None);
+            assert_eq!(message.body(), PLAIN);
+            assert!(!message.is_corrupted());
+        }
+
+        #[test]
+        fn gzip_body_is_decompressed() {
+            let message = view(gzip(PLAIN), pb::Encoding::Gzip, None);
+            assert_eq!(message.body(), PLAIN);
+            assert!(!message.is_corrupted());
+        }
+
+        #[test]
+        fn zlib_body_marked_as_gzip_is_decompressed() {
+            // The proxy marks every body stored with COMPRESSED_FLAG as GZIP, 
but the remoting
+            // producer compresses with ZLIB by default.
+            let message = view(zlib(PLAIN), pb::Encoding::Gzip, None);
+            assert_eq!(message.body(), PLAIN);
+            assert!(!message.is_corrupted());
+        }
+
+        #[test]
+        fn undecodable_body_is_corrupted() {
+            let garbage = vec![0x1f, 0x8b, 0x00, 0x01, 0x02];
+            let message = view(garbage.clone(), pb::Encoding::Gzip, None);
+            assert_eq!(message.body(), garbage.as_slice());
+            assert!(message.is_corrupted());
+
+            let unknown_format = b"not compressed".to_vec();
+            let message = view(unknown_format.clone(), pb::Encoding::Gzip, 
None);
+            assert_eq!(message.body(), unknown_format.as_slice());
+            assert!(message.is_corrupted());
+        }
+
+        #[test]
+        fn digest_is_verified_on_the_received_body() {
+            // The proxy computes the digest on the stored, i.e. still 
compressed, body.
+            let compressed = zlib(PLAIN);
+            let message = view(
+                compressed.clone(),
+                pb::Encoding::Gzip,
+                digest(pb::DigestType::Md5, md5_hex(&compressed)),
+            );
+            assert_eq!(message.body(), PLAIN);
+            assert!(!message.is_corrupted());
+
+            let message = view(
+                compressed,
+                pb::Encoding::Gzip,
+                digest(pb::DigestType::Md5, md5_hex(PLAIN)),
+            );
+            assert!(message.is_corrupted());
+        }
+
+        #[test]
+        fn every_digest_type_is_verified() {
+            let crc32 = format!("{:X}", crc32fast::hash(PLAIN));
+            let sha1 = hex::encode_upper(
+                ring::digest::digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, 
PLAIN).as_ref(),
+            );
+            for (r#type, checksum) in [
+                (pb::DigestType::Crc32, crc32),
+                (pb::DigestType::Md5, md5_hex(PLAIN)),
+                (pb::DigestType::Sha1, sha1),
+            ] {
+                let message = view(
+                    PLAIN.to_vec(),
+                    pb::Encoding::Identity,
+                    digest(r#type, checksum.clone()),
+                );
+                assert!(!message.is_corrupted(), "{:?} {}", r#type, checksum);
+
+                let message = view(
+                    PLAIN.to_vec(),
+                    pb::Encoding::Identity,
+                    digest(r#type, "0".to_string()),
+                );
+                assert!(message.is_corrupted(), "{:?}", r#type);
+            }
+        }
+    }
+
     #[test]
     fn common_test_message() {
         let mut properties = HashMap::new();
diff --git a/rust/src/push_consumer.rs b/rust/src/push_consumer.rs
index b4c5fcff..d16c8d88 100644
--- a/rust/src/push_consumer.rs
+++ b/rust/src/push_consumer.rs
@@ -816,6 +816,20 @@ impl StandardConsumerWorker {
         )
         .await?;
         for message in messages {
+            // Reference Java: `StandardConsumeService#consume` nacks a 
corrupted message
+            // instead of passing it to the listener.
+            if message.is_corrupted() {
+                error!(
+                    "Message is corrupted for standard consumption, prepare to 
discard it, topic={}, message_id={}",
+                    message.topic(),
+                    message.message_id()
+                );
+                let delay = 
retry_policy.get_next_attempt_delay(message.delivery_attempt());
+                ack_processor
+                    .change_invisible_duration(message, delay)
+                    .await;
+                continue;
+            }
             let consume_result = consume_message(&self.message_listener, 
&message);
             match consume_result {
                 ConsumeResult::SUCCESS => {
@@ -958,6 +972,20 @@ impl FifoConsumerWorker {
         retry_policy: &BackOffRetryPolicy,
     ) {
         let max_delivery_attempts = retry_policy.get_max_attempts();
+        // Reference Java: `FifoConsumeService#getNextValidMessage` forwards a 
corrupted message
+        // to the dead letter queue instead of passing it to the listener.
+        if message.is_corrupted() {
+            error!(
+                "Message is corrupted for FIFO consumption, prepare to discard 
it, topic={}, message_id={}",
+                message.topic(),
+                message.message_id()
+            );
+            let delivery_attempt = message.delivery_attempt();
+            ack_processor
+                .forward_to_deadletter_queue(message, delivery_attempt, 
max_delivery_attempts)
+                .await;
+            return;
+        }
         loop {
             let consume_result = consume_message(message_listener, &message);
             match consume_result {
@@ -2663,4 +2691,140 @@ mod tests {
         assert!(result.is_ok(), "{:?}", result);
         assert_eq!(vec![2], *attempts.lock());
     }
+
+    /// A message whose GZIP body can not be decompressed.
+    fn new_corrupted_message() -> pb::Message {
+        let mut message = new_message();
+        let system_properties = message.system_properties.as_mut().unwrap();
+        system_properties.body_encoding = pb::Encoding::Gzip as i32;
+        message.body = b"not compressed".to_vec();
+        message
+    }
+
+    fn new_listener_counter() -> (Arc<AtomicUsize>, Arc<MessageListener>) {
+        let count = Arc::new(AtomicUsize::new(0));
+        let counter = Arc::clone(&count);
+        let listener: Arc<MessageListener> = Arc::new(Box::new(move |_| {
+            counter.fetch_add(1, Ordering::Relaxed);
+            ConsumeResult::SUCCESS
+        }));
+        (count, listener)
+    }
+
+    #[tokio::test]
+    async fn test_standard_consume_discards_corrupted_message() {
+        let mut rpc_client = Session::default();
+        rpc_client.expect_receive_message().returning(|_| {
+            Ok(vec![ReceiveMessageResponse {
+                content: Some(Content::Message(new_corrupted_message())),
+            }])
+        });
+        let (listener_calls, listener) = new_listener_counter();
+        let consumer_worker = StandardConsumerWorker::new(rpc_client, 
listener);
+        let retry_policy = 
&BackOffRetryPolicy::Exponential(ExponentialBackOffRetryPolicy::new(
+            pb::ExponentialBackoff {
+                initial: Some(prost_types::Duration::from_str("1s").unwrap()),
+                max: Some(prost_types::Duration::from_str("60s").unwrap()),
+                multiplier: 2.0,
+            },
+            3,
+        ));
+        let mut option = PushConsumerOption::default();
+        option.subscribe("test_topic", FilterExpression::new(FilterType::Tag, 
"*"));
+        let mut session = Session::default();
+        session.expect_shadow_session().returning(Session::default);
+        session.expect_ack_message().never();
+        session
+            .expect_change_invisible_duration()
+            .times(1)
+            .returning(|_| {
+                Ok(ChangeInvisibleDurationResponse {
+                    status: Some(Status {
+                        code: Code::Ok as i32,
+                        message: "".to_string(),
+                    }),
+                    receipt_handle: "".to_string(),
+                })
+            });
+        let mut ack_processor = AckEntryProcessor::new(
+            session,
+            Resource {
+                name: "test_group".to_string(),
+                resource_namespace: "".to_string(),
+            },
+            Resource {
+                name: "test_topic".to_string(),
+                resource_namespace: "".to_string(),
+            },
+            false,
+        );
+        let result = ConsumerWorker::Standard(consumer_worker)
+            .receive_messages(
+                &new_message_queue(),
+                &FilterExpression::sub_all(),
+                &option,
+                &mut ack_processor,
+                retry_policy,
+            )
+            .await;
+        assert!(result.is_ok(), "{:?}", result);
+        assert_eq!(0, listener_calls.load(Ordering::Relaxed));
+    }
+
+    #[tokio::test]
+    async fn test_fifo_consume_forwards_corrupted_message_to_dlq() {
+        let mut rpc_client = Session::default();
+        rpc_client.expect_receive_message().returning(|_| {
+            Ok(vec![ReceiveMessageResponse {
+                content: Some(Content::Message(new_corrupted_message())),
+            }])
+        });
+        let (listener_calls, listener) = new_listener_counter();
+        let consumer_worker = FifoConsumerWorker::new(rpc_client, listener);
+        let retry_policy = 
&BackOffRetryPolicy::Customized(CustomizedBackOffRetryPolicy::new(
+            pb::CustomizedBackoff {
+                next: vec![prost_types::Duration::from_str("0.01s").unwrap()],
+            },
+            1,
+        ));
+        let mut option = PushConsumerOption::default();
+        option.subscribe("test_topic", FilterExpression::new(FilterType::Tag, 
"*"));
+        let mut session = Session::default();
+        session.expect_shadow_session().returning(Session::default);
+        session.expect_ack_message().never();
+        session
+            .expect_forward_to_deadletter_queue()
+            .times(1)
+            .returning(|_| {
+                Ok(ForwardMessageToDeadLetterQueueResponse {
+                    status: Some(Status {
+                        code: Code::Ok as i32,
+                        message: "".to_string(),
+                    }),
+                })
+            });
+        let mut ack_processor = AckEntryProcessor::new(
+            session,
+            Resource {
+                name: "test_group".to_string(),
+                resource_namespace: "".to_string(),
+            },
+            Resource {
+                name: "test_topic".to_string(),
+                resource_namespace: "".to_string(),
+            },
+            false,
+        );
+        let result = ConsumerWorker::Fifo(consumer_worker)
+            .receive_messages(
+                &new_message_queue(),
+                &FilterExpression::sub_all(),
+                &option,
+                &mut ack_processor,
+                retry_policy,
+            )
+            .await;
+        assert!(result.is_ok(), "{:?}", result);
+        assert_eq!(0, listener_calls.load(Ordering::Relaxed));
+    }
 }

Reply via email to