camel_processor/data_format/
tar_gz.rs1use 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;
10const DEFAULT_MAX_INPUT_SIZE: u64 = 64 * 1024 * 1024; const TAR_FRAMING_BOUND: u64 = 512 + 512 + 1024;
22
23#[derive(Debug, Clone, Deserialize)]
24#[serde(default, deny_unknown_fields)]
25pub struct TarGzConfig {
26 pub max_decompressed_size: u64,
29 pub max_input_size: u64,
31 pub allow_multi_entry: bool,
34 #[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#[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 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 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 fn marshal(&self, body: Body) -> Result<Body, CamelError> {
102 self.gzip.marshal(self.tar.marshal(body)?)
103 }
104
105 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 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 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 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 let config = TarGzConfig {
269 max_decompressed_size: 1024,
270 ..Default::default()
271 };
272 let df = TarGzDataFormat::new(config);
273
274 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 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 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 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 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 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 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 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 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 assert!(df.marshal(Body::Empty).is_err());
417
418 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 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}