Skip to main content

camel_processor/data_format/
gzip.rs

1use bytes::Bytes;
2use camel_api::body::Body;
3use camel_api::data_format::DataFormat;
4use camel_api::error::CamelError;
5use flate2::Compression;
6use flate2::write::GzEncoder;
7use serde::Deserialize;
8use std::io::{Read, Write};
9
10const DEFAULT_MAX_DECOMPRESSED_SIZE: u64 = 1_073_741_824;
11/// Default cap on the materialized input size of `marshal` (R3-L1). The eager
12/// marshal collects the whole body into a `Vec<u8>` before compressing; this
13/// bounds that allocation.
14const DEFAULT_MAX_INPUT_SIZE: u64 = 64 * 1024 * 1024; // 64 MiB
15
16/// Validates the raw level at serde parse time so an out-of-range value fails
17/// closed (`RouteError` from the config factory) instead of reaching the
18/// encoder. Shared with `tar_gz`, whose config carries the same field. The
19/// public config exposes a primitive `Option<u8>` so `flate2` types do not
20/// leak through the frozen API; the encoder level is derived internally.
21pub(super) fn deserialize_compression_level<'de, D>(deserializer: D) -> Result<Option<u8>, D::Error>
22where
23    D: serde::Deserializer<'de>,
24{
25    let raw: Option<u32> = Option::deserialize(deserializer)?;
26    match raw {
27        None => Ok(None),
28        Some(level) if level <= 9 => Ok(Some(level as u8)),
29        Some(level) => Err(serde::de::Error::custom(format!(
30            "compression_level must be between 0 and 9, got {level}"
31        ))),
32    }
33}
34
35/// Converts the public primitive level into a `flate2::Compression`. Serde
36/// already rejects out-of-range values; this guards programmatic construction
37/// and fails closed at `marshal` time (mirrors the zip format).
38fn to_flate2_compression(level: Option<u8>) -> Result<Compression, CamelError> {
39    match level {
40        None => Ok(Compression::default()),
41        Some(level) if level <= 9 => Ok(Compression::new(u32::from(level))),
42        Some(level) => Err(CamelError::TypeConversionFailed(format!(
43            "GzipDataFormat::marshal compression_level must be 0-9, got {level}"
44        ))),
45    }
46}
47
48#[derive(Debug, Clone, Deserialize)]
49#[serde(default, deny_unknown_fields)]
50pub struct GzipConfig {
51    pub max_decompressed_size: u64,
52    /// Maximum materialized input size accepted by `marshal` (DoS cap, R3-L1).
53    pub max_input_size: u64,
54    /// Deflate level 0-9; `None` uses the flate2 default (level 6).
55    #[serde(deserialize_with = "deserialize_compression_level")]
56    pub compression_level: Option<u8>,
57}
58
59impl Default for GzipConfig {
60    fn default() -> Self {
61        Self {
62            max_decompressed_size: DEFAULT_MAX_DECOMPRESSED_SIZE,
63            max_input_size: DEFAULT_MAX_INPUT_SIZE,
64            compression_level: None,
65        }
66    }
67}
68
69#[derive(Debug, Clone, Default)]
70pub struct GzipDataFormat {
71    config: GzipConfig,
72}
73
74impl GzipDataFormat {
75    pub fn new(config: GzipConfig) -> Self {
76        Self { config }
77    }
78}
79
80/// Outcome of a bounded single-member GZIP decode.
81pub(crate) struct FirstGzipMember {
82    /// Decompressed bytes of the first member, capped at the caller's
83    /// `take_limit`.
84    pub data: Vec<u8>,
85    /// True when the input holds bytes beyond the end of the first member
86    /// (a concatenated second member or any trailing data).
87    pub has_trailing_input: bool,
88}
89
90/// Bounded decode of the first GZIP member, shared by the standalone
91/// `GzipDataFormat::unmarshal` (which keeps its historical first-member
92/// semantics by ignoring [`FirstGzipMember::has_trailing_input`]) and the
93/// TAR.GZ stream splitter (which rejects multi-member input).
94///
95/// `take_limit` caps the emitted bytes at one past the caller's real bound so
96/// an overshoot stays detectable without materializing the full stream.
97///
98/// Trailing-input detection: `flate2::read::GzDecoder` wraps the reader in a
99/// `BufReader`, so the underlying cursor position over-reads. This helper
100/// constructs the same `bufread::GzDecoder<BufReader<Cursor>>` stack directly
101/// and subtracts the unconsumed buffer bytes from the cursor position, which
102/// yields the exact compressed size of the first member.
103pub(crate) fn decode_first_member(
104    raw: &[u8],
105    take_limit: u64,
106) -> Result<FirstGzipMember, std::io::Error> {
107    let decoder =
108        flate2::bufread::GzDecoder::new(std::io::BufReader::new(std::io::Cursor::new(raw)));
109    let mut data = Vec::new();
110    let mut limited = decoder.take(take_limit);
111    limited.read_to_end(&mut data)?;
112
113    // Unwrap the Take, the `bufread::GzDecoder`, and the `BufReader`. The
114    // cursor position includes bytes pulled into the buffer but not yet
115    // consumed by the decoder, so subtract the buffered remainder to get the
116    // exact compressed size of the first member.
117    let buf_reader = limited.into_inner().into_inner();
118    let buffered = buf_reader.buffer().len() as u64;
119    let position = buf_reader.into_inner().position();
120    let consumed = position.saturating_sub(buffered);
121
122    Ok(FirstGzipMember {
123        data,
124        has_trailing_input: consumed < raw.len() as u64,
125    })
126}
127
128impl DataFormat for GzipDataFormat {
129    fn name(&self) -> &str {
130        "gzip"
131    }
132
133    fn marshal(&self, body: Body) -> Result<Body, CamelError> {
134        let content =
135            super::materialize_marshal_input("GzipDataFormat", &body, self.config.max_input_size)?;
136
137        let level = to_flate2_compression(self.config.compression_level)?;
138        let mut encoder = GzEncoder::new(Vec::new(), level);
139        encoder.write_all(&content).map_err(|e| {
140            CamelError::TypeConversionFailed(format!(
141                "GzipDataFormat::marshal failed to compress: {e}"
142            ))
143        })?;
144        let compressed = encoder.finish().map_err(|e| {
145            CamelError::TypeConversionFailed(format!(
146                "GzipDataFormat::marshal failed to finalize gzip stream: {e}"
147            ))
148        })?;
149
150        Ok(Body::Bytes(Bytes::from(compressed)))
151    }
152
153    fn unmarshal(&self, body: Body) -> Result<Body, CamelError> {
154        let raw = super::raw_unmarshal_body("GzipDataFormat", "GZIP data", &body)?;
155
156        // Read at most cap + 1 decompressed bytes so a decompression bomb
157        // never materializes more than one byte past the limit, and so the
158        // overshoot is detectable before returning. Trailing input after the
159        // first member is ignored: the standalone format keeps its
160        // historical first-member semantics.
161        let take_limit = self.config.max_decompressed_size.saturating_add(1);
162        let first = decode_first_member(&raw, take_limit).map_err(|e| {
163            CamelError::TypeConversionFailed(format!(
164                "GzipDataFormat::unmarshal invalid GZIP stream: {e}"
165            ))
166        })?;
167
168        if first.data.len() as u64 > self.config.max_decompressed_size {
169            return Err(CamelError::TypeConversionFailed(format!(
170                "GzipDataFormat::unmarshal decompressed size exceeds max_decompressed_size {}",
171                self.config.max_decompressed_size
172            )));
173        }
174
175        Ok(Body::Bytes(Bytes::from(first.data)))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use bytes::Bytes;
183
184    use super::super::test_util::{assert_bytes, stream_body_pair};
185
186    #[test]
187    fn test_name() {
188        let df = GzipDataFormat::default();
189        assert_eq!(df.name(), "gzip");
190    }
191
192    #[test]
193    fn test_gzip_config_deserialize_from_json() {
194        let json = serde_json::json!({
195            "max_decompressed_size": 2147483648u64,
196            "max_input_size": 134217728u64,
197            "compression_level": 9
198        });
199        let cfg: GzipConfig = serde_json::from_value(json).unwrap();
200        assert_eq!(cfg.max_decompressed_size, 2147483648);
201        assert_eq!(cfg.max_input_size, 134217728);
202        assert_eq!(cfg.compression_level, Some(9));
203    }
204
205    #[test]
206    fn test_gzip_config_compression_level_boundaries() {
207        for level in [0u8, 9] {
208            let json = serde_json::json!({ "compression_level": level });
209            let cfg: GzipConfig = serde_json::from_value(json).unwrap();
210            assert_eq!(cfg.compression_level, Some(level));
211        }
212    }
213
214    #[test]
215    fn test_gzip_programmatic_out_of_range_level_fails_closed() {
216        let df = GzipDataFormat::new(GzipConfig {
217            compression_level: Some(10),
218            ..Default::default()
219        });
220        let result = df.marshal(Body::Bytes(Bytes::from_static(b"payload")));
221        let msg = format!("{}", result.unwrap_err());
222        assert!(
223            msg.contains("compression_level must be 0-9"),
224            "error should mention the level bound: {msg}"
225        );
226    }
227
228    #[test]
229    fn gzip_round_trip_with_explicit_compression_levels() {
230        for level in [0u8, 9] {
231            let df = GzipDataFormat::new(GzipConfig {
232                compression_level: Some(level),
233                ..Default::default()
234            });
235            let original = Body::Bytes(Bytes::from_static(b"explicit level payload"));
236            let restored = df.unmarshal(df.marshal(original.clone()).unwrap()).unwrap();
237            assert_eq!(restored, original, "round trip failed for level {level}");
238        }
239    }
240
241    #[test]
242    fn test_gzip_config_deny_unknown_fields() {
243        let json = serde_json::json!({"unknown_key": 42});
244        let result: Result<GzipConfig, _> = serde_json::from_value(json);
245        assert!(result.is_err());
246    }
247
248    #[test]
249    fn test_gzip_config_invalid_compression_level_fails_closed() {
250        let json = serde_json::json!({"compression_level": 10});
251        let result: Result<GzipConfig, _> = serde_json::from_value(json);
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn gzip_round_trip_bytes() {
257        let df = GzipDataFormat::default();
258        let original = Body::Bytes(Bytes::from_static(b"\x00\x01\x02\xffbinary payload"));
259        let compressed = df.marshal(original.clone()).unwrap();
260        let compressed_bytes = match &compressed {
261            Body::Bytes(b) => b.clone(),
262            _ => panic!("expected Body::Bytes"),
263        };
264        assert_eq!(
265            &compressed_bytes[..2],
266            &[0x1f, 0x8b],
267            "marshal must emit standalone gzip bytes"
268        );
269        let restored = df.unmarshal(compressed).unwrap();
270        assert_eq!(restored, original);
271    }
272
273    #[test]
274    fn gzip_tar_cross_format_round_trip() {
275        // Composed `tar` then `gzip` must decode back through `gzip` then
276        // `tar` to the original bytes (wire-compatibility contract).
277        let tar_df = super::super::tar::TarDataFormat::default();
278        let gzip_df = GzipDataFormat::default();
279        let original = Body::Bytes(Bytes::from_static(b"\x00\x01\x02\xfftar.gz payload"));
280
281        let archived = tar_df.marshal(original.clone()).unwrap();
282        let compressed = gzip_df.marshal(archived.clone()).unwrap();
283        let decompressed = gzip_df.unmarshal(compressed).unwrap();
284        assert_eq!(
285            decompressed, archived,
286            "gzip layer must preserve the TAR archive exactly"
287        );
288        let restored = tar_df.unmarshal(decompressed).unwrap();
289        assert_eq!(restored, original);
290    }
291
292    #[test]
293    fn gzip_data_format_first_member_behavior_is_unchanged() {
294        let df = GzipDataFormat::default();
295        let first = match df
296            .marshal(Body::Bytes(Bytes::from_static(b"first member payload")))
297            .unwrap()
298        {
299            Body::Bytes(b) => b.to_vec(),
300            other => panic!("expected Body::Bytes: {other:?}"),
301        };
302        let second = match df
303            .marshal(Body::Bytes(Bytes::from_static(b"second member payload")))
304            .unwrap()
305        {
306            Body::Bytes(b) => b.to_vec(),
307            other => panic!("expected Body::Bytes: {other:?}"),
308        };
309        let mut concatenated = first;
310        concatenated.extend_from_slice(&second);
311
312        // The standalone format keeps its historical first-member semantics:
313        // concatenated members decode to the first member's payload only.
314        let restored = df
315            .unmarshal(Body::Bytes(Bytes::from(concatenated)))
316            .unwrap();
317        assert_bytes(restored, b"first member payload");
318    }
319
320    #[test]
321    fn gzip_decompression_limit_covers_full_stream() {
322        let config = GzipConfig {
323            max_decompressed_size: 16,
324            ..Default::default()
325        };
326        let df = GzipDataFormat::new(config);
327        // 4 KiB of payload decompresses far past the 16-byte cap; the decoder
328        // must stop at cap + 1 bytes instead of materializing the full stream.
329        let compressed = GzipDataFormat::default()
330            .marshal(Body::Bytes(Bytes::from(vec![b'A'; 4096])))
331            .unwrap();
332        let result = df.unmarshal(compressed);
333        match result {
334            Err(CamelError::TypeConversionFailed(msg)) => {
335                assert!(
336                    msg.contains("max_decompressed_size"),
337                    "error should mention max_decompressed_size: {msg}"
338                );
339            }
340            _ => panic!("decompressed stream beyond the cap must be rejected"),
341        }
342    }
343
344    #[test]
345    fn malformed_gzip_input_rejected() {
346        let df = GzipDataFormat::default();
347
348        // Garbage bytes fail the gzip magic/header check.
349        let garbage = vec![b'G'; 64];
350        let result = df.unmarshal(Body::Bytes(Bytes::from(garbage)));
351        match result {
352            Err(CamelError::TypeConversionFailed(_)) => {}
353            _ => panic!("garbage input must yield TypeConversionFailed"),
354        }
355
356        // A stream truncated before the CRC/ISIZE trailer fails validation.
357        let full = match GzipDataFormat::default()
358            .marshal(Body::Bytes(Bytes::from_static(b"hello world")))
359        {
360            Ok(Body::Bytes(b)) => b.to_vec(),
361            other => panic!("marshal must yield compressed bytes: {other:?}"),
362        };
363        let truncated = full[..full.len() - 4].to_vec();
364        let result = df.unmarshal(Body::Bytes(Bytes::from(truncated)));
365        match result {
366            Err(CamelError::TypeConversionFailed(_)) => {}
367            _ => panic!("truncated gzip input must yield TypeConversionFailed"),
368        }
369    }
370
371    #[test]
372    fn gzip_materialized_empty_and_stream_bodies() {
373        let df = GzipDataFormat::default();
374
375        // Zero-length materialized payloads are valid bodies: marshal yields
376        // a well-formed gzip stream; unmarshal returns the empty payload.
377        for body in [Body::Bytes(Bytes::new()), Body::Text(String::new())] {
378            let compressed = df.marshal(body).unwrap();
379            let restored = df.unmarshal(compressed).unwrap();
380            assert_bytes(restored, b"");
381        }
382
383        // Body::Empty fails marshal.
384        assert!(df.marshal(Body::Empty).is_err());
385
386        // Body::Stream fails marshal and unmarshal without consuming the
387        // stream.
388        let (body, slot) = stream_body_pair();
389        assert!(df.marshal(body).is_err());
390        assert!(
391            slot.blocking_lock().is_some(),
392            "marshal must not consume the stream"
393        );
394
395        let (body, slot) = stream_body_pair();
396        assert!(df.unmarshal(body).is_err());
397        assert!(
398            slot.blocking_lock().is_some(),
399            "unmarshal must not consume the stream"
400        );
401    }
402
403    #[test]
404    fn test_marshal_input_size_cap() {
405        let config = GzipConfig {
406            max_input_size: 16,
407            ..Default::default()
408        };
409        let df = GzipDataFormat::new(config);
410        let result = df.marshal(Body::Text("x".repeat(64)));
411        assert!(result.is_err());
412        let msg = format!("{}", result.unwrap_err());
413        assert!(
414            msg.contains("max_input_size"),
415            "error should mention max_input_size: {msg}"
416        );
417    }
418}