xmtp_content_types/
compression.rs1use std::io::{self, Read, Write};
4
5use flate2::{
6 Compression as FlateCompression,
7 read::{DeflateDecoder, MultiGzDecoder, ZlibDecoder},
8 write::{GzEncoder, ZlibEncoder},
9};
10use xmtp_proto::xmtp::mls::message_contents::{Compression, EncodedContent};
11
12use crate::CodecError;
13
14pub const MAX_DECOMPRESSED_BYTES: usize = 16_777_216;
15pub const COMPRESSION_CHUNK_BYTES: usize = 64 * 1024;
16
17#[derive(Debug)]
18enum DecompressFailure {
19 Invalid(io::Error),
20 Limit,
21}
22
23#[derive(Debug)]
25pub struct DecompressionBudget {
26 remaining: usize,
27 peak_capacity: usize,
28}
29
30impl Default for DecompressionBudget {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl DecompressionBudget {
37 pub const fn new() -> Self {
38 Self {
39 remaining: MAX_DECOMPRESSED_BYTES,
40 peak_capacity: 0,
41 }
42 }
43
44 pub const fn peak_capacity(&self) -> usize {
46 self.peak_capacity
47 }
48
49 pub const fn used(&self) -> usize {
51 MAX_DECOMPRESSED_BYTES - self.remaining
52 }
53}
54
55fn read_bounded(
56 reader: &mut impl Read,
57 budget: &mut DecompressionBudget,
58) -> Result<Vec<u8>, DecompressFailure> {
59 let mut output = Vec::new();
60 let mut chunk = [0u8; COMPRESSION_CHUNK_BYTES];
61 loop {
62 let remaining = budget.remaining - output.len();
63 let request = remaining.saturating_add(1).min(COMPRESSION_CHUNK_BYTES);
64 let read = reader
65 .read(&mut chunk[..request])
66 .map_err(DecompressFailure::Invalid)?;
67 if read == 0 {
68 budget.remaining -= output.len();
69 return Ok(output);
70 }
71 if read > remaining {
72 return Err(DecompressFailure::Limit);
73 }
74 output.reserve_exact(read);
75 output.extend_from_slice(&chunk[..read]);
76 budget.peak_capacity = budget.peak_capacity.max(output.capacity());
77 }
78}
79
80pub fn compress(
83 mut content: EncodedContent,
84 algorithm: Compression,
85) -> Result<EncodedContent, CodecError> {
86 if content.compression.is_some() {
87 return Err(CodecError::Encode("content is already compressed".into()));
88 }
89 let bytes = match algorithm {
90 Compression::Deflate => {
91 let mut encoder = ZlibEncoder::new(Vec::new(), FlateCompression::default());
92 encoder
93 .write_all(&content.content)
94 .map_err(|e| CodecError::Encode(e.to_string()))?;
95 encoder
96 .finish()
97 .map_err(|e| CodecError::Encode(e.to_string()))?
98 }
99 Compression::Gzip => {
100 let mut encoder = GzEncoder::new(Vec::new(), FlateCompression::default());
101 encoder
102 .write_all(&content.content)
103 .map_err(|e| CodecError::Encode(e.to_string()))?;
104 encoder
105 .finish()
106 .map_err(|e| CodecError::Encode(e.to_string()))?
107 }
108 };
109 content.content = bytes;
110 content.compression = Some(algorithm as i32);
111 Ok(content)
112}
113
114pub fn compress_if_requested(
116 content: EncodedContent,
117 algorithm: Option<Compression>,
118) -> Result<EncodedContent, CodecError> {
119 match algorithm {
120 Some(algorithm) => compress(content, algorithm),
121 None => Ok(content),
122 }
123}
124
125pub fn decompress(content: EncodedContent) -> Result<EncodedContent, CodecError> {
128 decompress_with_budget(content, &mut DecompressionBudget::new())
129}
130
131pub fn decompress_with_budget(
133 mut content: EncodedContent,
134 budget: &mut DecompressionBudget,
135) -> Result<EncodedContent, CodecError> {
136 let Some(raw_algorithm) = content.compression else {
137 return Ok(content);
138 };
139 let algorithm = Compression::try_from(raw_algorithm)
140 .map_err(|_| CodecError::Decode(format!("unknown compression value {raw_algorithm}")))?;
141 let result = match algorithm {
142 Compression::Gzip => {
143 read_bounded(&mut MultiGzDecoder::new(content.content.as_slice()), budget)
144 }
145 Compression::Deflate => {
146 match read_bounded(&mut ZlibDecoder::new(content.content.as_slice()), budget) {
147 Ok(bytes) => Ok(bytes),
148 Err(DecompressFailure::Invalid(_)) => {
149 read_bounded(&mut DeflateDecoder::new(content.content.as_slice()), budget)
150 }
151 Err(DecompressFailure::Limit) => Err(DecompressFailure::Limit),
152 }
153 }
154 };
155 content.content = result.map_err(|error| match error {
156 DecompressFailure::Invalid(error) => {
157 CodecError::Decode(format!("decompression failed: {error}"))
158 }
159 DecompressFailure::Limit => CodecError::Decode(format!(
160 "decompressed content exceeds {MAX_DECOMPRESSED_BYTES} bytes"
161 )),
162 })?;
163 content.compression = None;
164 Ok(content)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::{ContentCodec, text::TextCodec};
171
172 #[xmtp_common::test(unwrap_try = true)]
175 async fn decompress_ios_raw_deflate_vector() {
176 let mut content = TextCodec::encode(String::new())?;
177 content.content = hex::decode("cb48cdc9c95748cecf2d284a2d2e4e4d5188f00d090000")?;
178 content.compression = Some(Compression::Deflate as i32);
179 assert_eq!(
180 TextCodec::decode(decompress(content)?)?,
181 "hello compressed XMTP"
182 );
183 }
184
185 #[xmtp_common::test(unwrap_try = true)]
188 async fn decompress_kotlin_zlib_vector() {
189 let mut content = TextCodec::encode(String::new())?;
190 content.content =
191 hex::decode("789ccb48cdc9c95748cecf2d284a2d2e4e4d5188f00d090000599907d3")?;
192 content.compression = Some(Compression::Deflate as i32);
193 assert_eq!(
194 TextCodec::decode(decompress(content)?)?,
195 "hello compressed XMTP"
196 );
197 }
198
199 #[xmtp_common::test(unwrap_try = true)]
203 async fn lzfse_labelled_gzip_is_decode_failure() {
204 let mut content = TextCodec::encode(String::new())?;
205 content.content = hex::decode(
206 "6276786ea800000026000000e00568656c6c6f20636f6d7072657373656420584d54503815f077e25450060000000000000062767824",
207 )?;
208 content.compression = Some(Compression::Gzip as i32);
209 assert!(matches!(decompress(content), Err(CodecError::Decode(_))));
210 }
211
212 #[xmtp_common::test(unwrap_try = true)]
214 async fn decompression_stops_at_limit() {
215 struct CountedRead<R> {
216 inner: R,
217 bytes_read: usize,
218 }
219
220 impl<R: Read> Read for CountedRead<R> {
221 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
222 let read = self.inner.read(buffer)?;
223 self.bytes_read += read;
224 Ok(read)
225 }
226 }
227
228 let source = vec![b'A'; MAX_DECOMPRESSED_BYTES + 2 * COMPRESSION_CHUNK_BYTES];
229 let mut encoder = ZlibEncoder::new(Vec::new(), FlateCompression::default());
230 encoder.write_all(&source)?;
231 let compressed = encoder.finish()?;
232 assert!(compressed.len() < 20_000);
233 let mut budget = DecompressionBudget::new();
234 let mut reader = CountedRead {
235 inner: ZlibDecoder::new(compressed.as_slice()),
236 bytes_read: 0,
237 };
238 let result = read_bounded(&mut reader, &mut budget);
239 assert!(matches!(result, Err(DecompressFailure::Limit)));
240 assert_eq!(reader.bytes_read, MAX_DECOMPRESSED_BYTES + 1);
242 assert!(budget.peak_capacity() <= MAX_DECOMPRESSED_BYTES + COMPRESSION_CHUNK_BYTES);
243 let mut content = TextCodec::encode(String::new())?;
244 content.content = compressed;
245 content.compression = Some(Compression::Deflate as i32);
246 assert!(matches!(decompress(content), Err(CodecError::Decode(_))));
247 }
248
249 #[xmtp_common::test(unwrap_try = true)]
251 async fn stored_block_then_compressed_tail_stays_within_capacity_limit() {
252 use flate2::write::DeflateEncoder;
253
254 let first = vec![b'B'; 49_052];
255 let tail = vec![b'A'; MAX_DECOMPRESSED_BYTES + 1 - first.len()];
256 let mut raw_tail = DeflateEncoder::new(Vec::new(), FlateCompression::default());
257 raw_tail.write_all(&tail)?;
258 let raw_tail = raw_tail.finish()?;
259
260 let length = u16::try_from(first.len())?;
262 let mut compressed = vec![0x78, 0x9c, 0x00];
263 compressed.extend_from_slice(&length.to_le_bytes());
264 compressed.extend_from_slice(&(!length).to_le_bytes());
265 compressed.extend_from_slice(&first);
266 compressed.extend_from_slice(&raw_tail);
267 let mut s1 = 1u32;
268 let mut s2 = 0u32;
269 for byte in first.iter().chain(&tail) {
270 s1 = (s1 + u32::from(*byte)) % 65_521;
271 s2 = (s2 + s1) % 65_521;
272 }
273 compressed.extend_from_slice(&((s2 << 16) | s1).to_be_bytes());
274
275 let mut budget = DecompressionBudget::new();
276 let result = read_bounded(&mut ZlibDecoder::new(compressed.as_slice()), &mut budget);
277 assert!(matches!(result, Err(DecompressFailure::Limit)));
278 assert!(budget.peak_capacity() <= MAX_DECOMPRESSED_BYTES + COMPRESSION_CHUNK_BYTES);
279 let mut content = TextCodec::encode(String::new())?;
280 content.content = compressed;
281 content.compression = Some(Compression::Deflate as i32);
282 assert!(matches!(decompress(content), Err(CodecError::Decode(_))));
283 }
284
285 #[xmtp_common::test(unwrap_try = true)]
287 async fn gzip_decodes_all_members() {
288 let mut content = TextCodec::encode(String::new())?;
289 let mut first = GzEncoder::new(Vec::new(), FlateCompression::default());
290 first.write_all(b"hello ")?;
291 let mut second = GzEncoder::new(Vec::new(), FlateCompression::default());
292 second.write_all(b"world")?;
293 content.content = first.finish()?;
294 content.content.extend(second.finish()?);
295 content.compression = Some(Compression::Gzip as i32);
296 assert_eq!(decompress(content)?.content, b"hello world");
297 }
298
299 #[xmtp_common::test(unwrap_try = true)]
301 async fn compress_round_trip() {
302 let original = TextCodec::encode("hello compressed XMTP".into())?;
303 assert_eq!(compress_if_requested(original.clone(), None)?, original);
304 for (algorithm, magic) in [
305 (Compression::Deflate, &[0x78][..]),
306 (Compression::Gzip, &[0x1f, 0x8b][..]),
307 ] {
308 let compressed = compress_if_requested(original.clone(), Some(algorithm))?;
309 assert_eq!(compressed.compression, Some(algorithm as i32));
310 assert!(compressed.content.starts_with(magic));
311 assert_eq!(decompress(compressed)?, original);
312 }
313 }
314}