Skip to main content

camel_processor/data_format/
tar_gz.rs

1use camel_api::body::Body;
2use camel_api::data_format::DataFormat;
3use camel_api::error::CamelError;
4use serde::Deserialize;
5
6use super::gzip::{GzipConfig, GzipDataFormat};
7use super::tar::{TarConfig, TarDataFormat};
8
9const DEFAULT_MAX_DECOMPRESSED_SIZE: u64 = 1_073_741_824;
10/// Default cap on the materialized input size of `marshal` (R3-L1). Enforced
11/// by the inner TAR layer on the raw body before archiving; this bounds that
12/// allocation.
13const DEFAULT_MAX_INPUT_SIZE: u64 = 64 * 1024 * 1024; // 64 MiB
14
15/// Worst-case TAR framing the inner gzip layer sees above the raw body: one
16/// 512-byte header, up to 511 bytes of payload padding, and the 1024-byte
17/// end-of-archive marker (rounded up to 2048). The inner gzip `max_input_size`
18/// is widened by this bound so a body accepted by the outer cap always
19/// produces an archive the gzip layer accepts — the effective raw-body bound
20/// is unchanged because the TAR layer rejects oversized bodies first.
21const TAR_FRAMING_BOUND: u64 = 512 + 512 + 1024;
22
23#[derive(Debug, Clone, Deserialize)]
24#[serde(default, deny_unknown_fields)]
25pub struct TarGzConfig {
26    /// Cap on every byte the gzip decoder emits during `unmarshal` — headers,
27    /// padding, and skipped entries included, not only the selected payload.
28    pub max_decompressed_size: u64,
29    /// Maximum materialized input size accepted by `marshal` (DoS cap, R3-L1).
30    pub max_input_size: u64,
31    /// TAR entry policy: when false, archives with more than one regular file
32    /// are rejected instead of returning only the first.
33    pub allow_multi_entry: bool,
34    /// Deflate level 0-9; `None` uses the flate2 default (level 6).
35    #[serde(deserialize_with = "super::gzip::deserialize_compression_level")]
36    pub compression_level: Option<u8>,
37}
38
39impl Default for TarGzConfig {
40    fn default() -> Self {
41        Self {
42            max_decompressed_size: DEFAULT_MAX_DECOMPRESSED_SIZE,
43            max_input_size: DEFAULT_MAX_INPUT_SIZE,
44            allow_multi_entry: false,
45            compression_level: None,
46        }
47    }
48}
49
50/// Combined `tar.gz` format implemented by composing the existing
51/// [`TarDataFormat`] and [`GzipDataFormat`]: `marshal = gzip(tar(body))` and
52/// `unmarshal = tar(gzip(body))`. The inner configs carry the outer caps so
53/// the composition is behaviorally identical to running the two formats in
54/// sequence.
55#[derive(Debug, Clone)]
56pub struct TarGzDataFormat {
57    tar: TarDataFormat,
58    gzip: GzipDataFormat,
59}
60
61impl Default for TarGzDataFormat {
62    fn default() -> Self {
63        Self::new(TarGzConfig::default())
64    }
65}
66
67impl TarGzDataFormat {
68    pub fn new(config: TarGzConfig) -> Self {
69        // The TAR layer materializes the raw body, so it enforces the outer
70        // `max_input_size` exactly where the allocation happens. Its
71        // `max_input_size` and per-entry output limits are irrelevant to
72        // `unmarshal` (the gzip layer bounds the stream first) but map to the
73        // same outer values so both directions share one bound.
74        let tar = TarDataFormat::new(TarConfig {
75            max_decompressed_size: config.max_decompressed_size,
76            max_input_size: config.max_input_size,
77            allow_multi_entry: config.allow_multi_entry,
78        });
79        // The gzip layer caps every decoded byte — TAR headers, padding, and
80        // skipped entries included — before any TAR parsing happens. On
81        // `marshal` it consumes the archive, whose size is the body plus
82        // bounded framing (see `TAR_FRAMING_BOUND`).
83        let gzip = GzipDataFormat::new(GzipConfig {
84            max_decompressed_size: config.max_decompressed_size,
85            max_input_size: config.max_input_size.saturating_add(TAR_FRAMING_BOUND),
86            compression_level: config.compression_level,
87        });
88        Self { tar, gzip }
89    }
90}
91
92impl DataFormat for TarGzDataFormat {
93    fn name(&self) -> &str {
94        "tar.gz"
95    }
96
97    /// `gzip(tar(body))`: the TAR layer writes one regular-file entry named
98    /// `payload` — never an attacker-controlled path — and enforces the
99    /// raw-body input cap; the gzip layer compresses the archive so `tar.gz`
100    /// output decodes through `gzip` then `tar`.
101    fn marshal(&self, body: Body) -> Result<Body, CamelError> {
102        self.gzip.marshal(self.tar.marshal(body)?)
103    }
104
105    /// `tar(gzip(body))`: the gzip layer first caps the full decoded stream
106    /// at `max_decompressed_size`, then the TAR layer performs regular-file
107    /// selection and the `allow_multi_entry` policy. Names are never used
108    /// for I/O and non-regular entries are skipped without materializing
109    /// their payload (path-confinement precedent rc-0ks57; v1 performs no
110    /// disk I/O).
111    fn unmarshal(&self, body: Body) -> Result<Body, CamelError> {
112        self.tar.unmarshal(self.gzip.unmarshal(body)?)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use bytes::Bytes;
120
121    use super::super::gzip::GzipDataFormat;
122    use super::super::tar::TarDataFormat;
123    use super::super::test_util::{
124        CHARACTER_DEVICE, DIRECTORY, HARDLINK, REGULAR, SYMLINK, assert_bytes, capture_warns,
125        make_tar, stream_body_pair,
126    };
127
128    fn gzip_bytes(raw: Vec<u8>) -> Body {
129        GzipDataFormat::default()
130            .marshal(Body::Bytes(Bytes::from(raw)))
131            .unwrap()
132    }
133
134    #[test]
135    fn test_name() {
136        let df = TarGzDataFormat::default();
137        assert_eq!(df.name(), "tar.gz");
138    }
139
140    #[test]
141    fn test_tar_gz_config_deserialize_from_json() {
142        let json = serde_json::json!({
143            "max_decompressed_size": 2147483648u64,
144            "max_input_size": 134217728u64,
145            "allow_multi_entry": true,
146            "compression_level": 9
147        });
148        let cfg: TarGzConfig = serde_json::from_value(json).unwrap();
149        assert_eq!(cfg.max_decompressed_size, 2147483648);
150        assert_eq!(cfg.max_input_size, 134217728);
151        assert!(cfg.allow_multi_entry);
152        assert_eq!(cfg.compression_level, Some(9));
153    }
154
155    #[test]
156    fn tar_gz_round_trip_with_explicit_compression_level() {
157        let df = TarGzDataFormat::new(TarGzConfig {
158            compression_level: Some(9),
159            ..Default::default()
160        });
161        let original = Body::Bytes(Bytes::from_static(b"level nine payload"));
162        let restored = df.unmarshal(df.marshal(original.clone()).unwrap()).unwrap();
163        assert_eq!(restored, original);
164    }
165
166    #[test]
167    fn tar_gz_programmatic_out_of_range_level_fails_closed() {
168        let df = TarGzDataFormat::new(TarGzConfig {
169            compression_level: Some(10),
170            ..Default::default()
171        });
172        let result = df.marshal(Body::Bytes(Bytes::from_static(b"payload")));
173        let msg = format!("{}", result.unwrap_err());
174        assert!(
175            msg.contains("compression_level must be 0-9"),
176            "error should mention the level bound: {msg}"
177        );
178    }
179
180    #[test]
181    fn test_tar_gz_config_deny_unknown_fields() {
182        let json = serde_json::json!({"unknown_key": 42});
183        let result: Result<TarGzConfig, _> = serde_json::from_value(json);
184        assert!(result.is_err());
185    }
186
187    #[test]
188    fn test_tar_gz_config_invalid_compression_level_fails_closed() {
189        let json = serde_json::json!({"compression_level": 10});
190        let result: Result<TarGzConfig, _> = serde_json::from_value(json);
191        assert!(result.is_err());
192    }
193
194    #[test]
195    fn tar_gz_round_trip_bytes() {
196        let gzip_df = GzipDataFormat::default();
197        let df = TarGzDataFormat::default();
198        let original = Body::Bytes(Bytes::from_static(b"\x00\x01\x02\xffbinary payload"));
199        let archived = df.marshal(original.clone()).unwrap();
200
201        let combined_bytes = match &archived {
202            Body::Bytes(b) => b.clone(),
203            _ => panic!("expected Body::Bytes"),
204        };
205        assert_eq!(
206            &combined_bytes[..2],
207            &[0x1f, 0x8b],
208            "marshal must emit gzip-wrapped bytes"
209        );
210
211        // Peeling the layers with the standalone formats must reveal exactly
212        // one regular entry named `payload`.
213        let tar_bytes = gzip_df.unmarshal(archived.clone()).unwrap();
214        let tar_bytes = match &tar_bytes {
215            Body::Bytes(b) => b.clone(),
216            _ => panic!("expected Body::Bytes"),
217        };
218        let mut names = Vec::new();
219        for entry in tar::Archive::new(std::io::Cursor::new(&tar_bytes[..]))
220            .entries()
221            .unwrap()
222        {
223            names.push(entry.unwrap().path().unwrap().to_path_buf());
224        }
225        assert_eq!(names.len(), 1, "marshal must write exactly one entry");
226        assert_eq!(names[0], std::path::Path::new("payload"));
227
228        let restored = df.unmarshal(archived).unwrap();
229        assert_eq!(restored, original);
230    }
231
232    #[test]
233    fn gzip_and_tar_gz_cross_decode() {
234        let tar_df = TarDataFormat::default();
235        let gzip_df = GzipDataFormat::default();
236        let df = TarGzDataFormat::default();
237        let original = Body::Bytes(Bytes::from_static(b"\x00\x01\x02\xffcross payload"));
238
239        // Direction A: tar.gz output decodes through gzip then tar.
240        let combined = df.marshal(original.clone()).unwrap();
241        let combined_bytes = match &combined {
242            Body::Bytes(b) => b.clone(),
243            _ => panic!("expected Body::Bytes"),
244        };
245        assert_eq!(
246            &combined_bytes[..2],
247            &[0x1f, 0x8b],
248            "tar.gz marshal must emit gzip-wrapped bytes"
249        );
250        let archived = gzip_df.unmarshal(combined.clone()).unwrap();
251        let restored = tar_df.unmarshal(archived).unwrap();
252        assert_eq!(restored, original);
253
254        // Direction B: composed tar then gzip decodes through tar.gz.
255        let composed = gzip_df
256            .marshal(tar_df.marshal(original.clone()).unwrap())
257            .unwrap();
258        let restored = df.unmarshal(composed).unwrap();
259        assert_eq!(restored, original);
260    }
261
262    #[test]
263    fn tar_gz_decompression_limit_includes_tar_stream() {
264        // The cap is set below the size of the TAR headers alone, while the
265        // regular-file payload is tiny. The cap must trip on the decoded
266        // headers/padding, proving it bounds the full gzip output — not just
267        // the selected payload.
268        let config = TarGzConfig {
269            max_decompressed_size: 1024,
270            ..Default::default()
271        };
272        let df = TarGzDataFormat::new(config);
273
274        // Three 512-byte directory headers precede one small regular file.
275        let tar_bytes = make_tar(&[
276            ("d0/", DIRECTORY, b""),
277            ("d1/", DIRECTORY, b""),
278            ("d2/", DIRECTORY, b""),
279            ("payload.txt", REGULAR, b"tiny"),
280        ]);
281        let compressed = gzip_bytes(tar_bytes);
282
283        let result = df.unmarshal(compressed);
284        match result {
285            Err(CamelError::TypeConversionFailed(msg)) => {
286                assert!(
287                    msg.contains("max_decompressed_size"),
288                    "error should mention max_decompressed_size: {msg}"
289                );
290            }
291            _ => panic!("decoded stream beyond the cap must be rejected"),
292        }
293    }
294
295    #[test]
296    fn malformed_tar_gz_input_rejected() {
297        let df = TarGzDataFormat::default();
298
299        // Garbage bytes fail the gzip magic/header check.
300        let garbage = vec![b'G'; 64];
301        let result = df.unmarshal(Body::Bytes(Bytes::from(garbage)));
302        match result {
303            Err(CamelError::TypeConversionFailed(_)) => {}
304            _ => panic!("garbage input must yield TypeConversionFailed"),
305        }
306
307        // A valid gzip stream that does not decode to a TAR fails TAR parsing.
308        let not_tar = gzip_bytes(vec![b'X'; 512]);
309        let result = df.unmarshal(not_tar);
310        match result {
311            Err(CamelError::TypeConversionFailed(_)) => {}
312            _ => panic!("gzip of non-TAR input must yield TypeConversionFailed"),
313        }
314
315        // A stream truncated before the CRC/ISIZE trailer fails validation.
316        let full = match df
317            .marshal(Body::Bytes(Bytes::from_static(b"hello world")))
318            .unwrap()
319        {
320            Body::Bytes(b) => b.to_vec(),
321            other => panic!("marshal must yield compressed bytes: {other:?}"),
322        };
323        let truncated = full[..full.len() - 4].to_vec();
324        let result = df.unmarshal(Body::Bytes(Bytes::from(truncated)));
325        match result {
326            Err(CamelError::TypeConversionFailed(_)) => {}
327            _ => panic!("truncated gzip input must yield TypeConversionFailed"),
328        }
329    }
330
331    #[test]
332    fn tar_gz_non_regular_entries_and_malicious_paths_are_ignored() {
333        let tar_bytes = make_tar(&[
334            ("../escape-dir/", DIRECTORY, b""),
335            ("/etc/passwd", SYMLINK, b""),
336            ("../escape-hardlink", HARDLINK, b""),
337            ("/dev/tty", CHARACTER_DEVICE, b""),
338            ("payload.txt", REGULAR, b"safe payload bytes"),
339        ]);
340        let df = TarGzDataFormat::default();
341        // The archive is scanned in memory only: no extraction, no path-based
342        // I/O, so the malicious names above are never resolved. Only the
343        // regular file is selected.
344        let restored = df.unmarshal(gzip_bytes(tar_bytes)).unwrap();
345        assert_bytes(restored, b"safe payload bytes");
346    }
347
348    #[test]
349    fn tar_gz_regular_entry_policy() {
350        let df_strict = TarGzDataFormat::default();
351        let df_multi = TarGzDataFormat::new(TarGzConfig {
352            allow_multi_entry: true,
353            ..Default::default()
354        });
355
356        // Zero regular files (only directory + symlink) errors under both
357        // configurations.
358        let no_regular = make_tar(&[("only-dir/", DIRECTORY, b""), ("only-link", SYMLINK, b"")]);
359        for df in [&df_strict, &df_multi] {
360            let result = df.unmarshal(gzip_bytes(no_regular.clone()));
361            match result {
362                Err(CamelError::TypeConversionFailed(_)) => {}
363                _ => panic!("archive without regular files must be rejected"),
364            }
365        }
366
367        // Exactly one regular file (plus non-regular entries) succeeds under
368        // both configurations.
369        let one_regular = make_tar(&[
370            ("dir/", DIRECTORY, b""),
371            ("payload.txt", REGULAR, b"single payload"),
372            ("link", HARDLINK, b""),
373        ]);
374        for df in [&df_strict, &df_multi] {
375            let restored = df.unmarshal(gzip_bytes(one_regular.clone())).unwrap();
376            assert_bytes(restored, b"single payload");
377        }
378
379        // Two regular files: strict config errors mentioning the policy;
380        // allow_multi_entry returns the first regular file and warns.
381        let two_regular = make_tar(&[
382            ("first.txt", REGULAR, b"first"),
383            ("second.txt", REGULAR, b"second"),
384        ]);
385        let err = df_strict
386            .unmarshal(gzip_bytes(two_regular.clone()))
387            .unwrap_err();
388        let msg = format!("{err}");
389        assert!(
390            msg.contains("allow_multi_entry"),
391            "error should mention allow_multi_entry: {msg}"
392        );
393
394        let (result, warnings) =
395            capture_warns(|| df_multi.unmarshal(gzip_bytes(two_regular.clone())));
396        assert_bytes(result.unwrap(), b"first");
397        assert!(
398            warnings.iter().any(|w| w.contains("multiple regular")),
399            "multi-entry path should warn, captured: {warnings:?}"
400        );
401    }
402
403    #[test]
404    fn tar_gz_materialized_empty_and_stream_bodies() {
405        let df = TarGzDataFormat::default();
406
407        // Zero-length materialized payloads are valid bodies: marshal yields
408        // a well-formed archive; unmarshal returns the empty payload.
409        for body in [Body::Bytes(Bytes::new()), Body::Text(String::new())] {
410            let archived = df.marshal(body).unwrap();
411            let restored = df.unmarshal(archived).unwrap();
412            assert_bytes(restored, b"");
413        }
414
415        // Body::Empty fails marshal.
416        assert!(df.marshal(Body::Empty).is_err());
417
418        // Body::Stream fails marshal and unmarshal without consuming the
419        // stream.
420        let (body, slot) = stream_body_pair();
421        assert!(df.marshal(body).is_err());
422        assert!(
423            slot.blocking_lock().is_some(),
424            "marshal must not consume the stream"
425        );
426
427        let (body, slot) = stream_body_pair();
428        assert!(df.unmarshal(body).is_err());
429        assert!(
430            slot.blocking_lock().is_some(),
431            "unmarshal must not consume the stream"
432        );
433    }
434
435    #[test]
436    fn test_marshal_input_size_cap() {
437        let config = TarGzConfig {
438            max_input_size: 16,
439            ..Default::default()
440        };
441        let df = TarGzDataFormat::new(config);
442        let result = df.marshal(Body::Text("x".repeat(64)));
443        assert!(result.is_err());
444        let msg = format!("{}", result.unwrap_err());
445        assert!(
446            msg.contains("max_input_size"),
447            "error should mention max_input_size: {msg}"
448        );
449    }
450
451    #[test]
452    fn tar_gz_marshal_accepts_body_at_exact_input_cap() {
453        // A body exactly at `max_input_size` passes the outer cap; the inner
454        // gzip layer must accept the archive too (TAR framing allowance) or
455        // the composition would silently tighten the outer bound.
456        let config = TarGzConfig {
457            max_input_size: 64,
458            ..Default::default()
459        };
460        let df = TarGzDataFormat::new(config);
461        let original = Body::Bytes(Bytes::from(vec![b'x'; 64]));
462        let restored = df.unmarshal(df.marshal(original.clone()).unwrap()).unwrap();
463        assert_eq!(restored, original);
464    }
465}