camel_processor/data_format/
tar.rs1use bytes::Bytes;
2use camel_api::body::Body;
3use camel_api::data_format::DataFormat;
4use camel_api::error::CamelError;
5use serde::Deserialize;
6use std::io::Read;
7
8const DEFAULT_MAX_DECOMPRESSED_SIZE: u64 = 1_073_741_824;
9const DEFAULT_MAX_INPUT_SIZE: u64 = 64 * 1024 * 1024; const ENTRY_NAME: &str = "payload";
14
15#[derive(Debug, Clone, Deserialize)]
16#[serde(default, deny_unknown_fields)]
17pub struct TarConfig {
18 pub max_decompressed_size: u64,
19 pub max_input_size: u64,
21 pub allow_multi_entry: bool,
22}
23
24impl Default for TarConfig {
25 fn default() -> Self {
26 Self {
27 max_decompressed_size: DEFAULT_MAX_DECOMPRESSED_SIZE,
28 max_input_size: DEFAULT_MAX_INPUT_SIZE,
29 allow_multi_entry: false,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct TarDataFormat {
36 config: TarConfig,
37}
38
39impl TarDataFormat {
40 pub fn new(config: TarConfig) -> Self {
41 Self { config }
42 }
43}
44
45impl DataFormat for TarDataFormat {
46 fn name(&self) -> &str {
47 "tar"
48 }
49
50 fn marshal(&self, body: Body) -> Result<Body, CamelError> {
51 let content =
52 super::materialize_marshal_input("TarDataFormat", &body, self.config.max_input_size)?;
53
54 let mut buf = Vec::new();
55 {
56 let mut builder = tar::Builder::new(&mut buf);
57 let mut header = tar::Header::new_gnu();
58 header.set_entry_type(tar::EntryType::Regular);
59 header.set_size(content.len() as u64);
60 header.set_mode(0o644);
61 header.set_cksum();
62 builder
63 .append_data(&mut header, ENTRY_NAME, content.as_slice())
64 .map_err(|e| {
65 CamelError::TypeConversionFailed(format!(
66 "TarDataFormat::marshal failed to write entry: {e}"
67 ))
68 })?;
69 builder.finish().map_err(|e| {
70 CamelError::TypeConversionFailed(format!(
71 "TarDataFormat::marshal failed to finalize archive: {e}"
72 ))
73 })?;
74 }
75
76 Ok(Body::Bytes(Bytes::from(buf)))
77 }
78
79 fn unmarshal(&self, body: Body) -> Result<Body, CamelError> {
80 let raw = super::raw_unmarshal_body("TarDataFormat", "TAR data", &body)?;
81
82 let mut archive = tar::Archive::new(std::io::Cursor::new(&raw));
87 let mut first_regular: Option<Vec<u8>> = None;
88 let mut regular_count: usize = 0;
89 {
90 let entries = archive.entries().map_err(|e| {
91 CamelError::TypeConversionFailed(format!(
92 "TarDataFormat::unmarshal invalid TAR: {e}"
93 ))
94 })?;
95 for entry in entries {
96 let entry = entry.map_err(|e| {
97 CamelError::TypeConversionFailed(format!(
98 "TarDataFormat::unmarshal invalid TAR entry: {e}"
99 ))
100 })?;
101 if entry.header().entry_type() != tar::EntryType::Regular {
102 continue;
103 }
104 regular_count += 1;
105 if first_regular.is_none() {
106 let limit = self.config.max_decompressed_size.saturating_add(1);
109 let mut limited = entry.take(limit);
110 let mut data = Vec::new();
111 limited.read_to_end(&mut data).map_err(|e| {
112 CamelError::TypeConversionFailed(format!(
113 "TarDataFormat::unmarshal failed to read entry: {e}"
114 ))
115 })?;
116 first_regular = Some(data);
117 }
118 }
119 }
120
121 let payload = first_regular.ok_or_else(|| {
122 CamelError::TypeConversionFailed(
123 "TarDataFormat::unmarshal TAR archive has no regular file".to_string(),
124 )
125 })?;
126
127 if regular_count > 1 && !self.config.allow_multi_entry {
128 return Err(CamelError::TypeConversionFailed(format!(
129 "TarDataFormat::unmarshal TAR has {regular_count} regular files but allow_multi_entry is false"
130 )));
131 }
132
133 if regular_count > 1 {
134 tracing::warn!(
135 regular_files = regular_count,
136 "TAR archive has multiple regular files, returning first only"
137 );
138 }
139
140 if payload.len() as u64 > self.config.max_decompressed_size {
141 return Err(CamelError::TypeConversionFailed(format!(
142 "TarDataFormat::unmarshal regular file size exceeds max_decompressed_size {}",
143 self.config.max_decompressed_size
144 )));
145 }
146
147 Ok(Body::Bytes(Bytes::from(payload)))
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use bytes::Bytes;
155
156 use super::super::test_util::{
157 CHARACTER_DEVICE, DIRECTORY, HARDLINK, REGULAR, SYMLINK, assert_bytes, capture_warns,
158 make_tar, make_tar_payload, stream_body_pair,
159 };
160
161 #[test]
162 fn test_name() {
163 let df = TarDataFormat::default();
164 assert_eq!(df.name(), "tar");
165 }
166
167 #[test]
168 fn test_tar_config_deserialize_from_json() {
169 let json = serde_json::json!({
170 "max_decompressed_size": 2147483648u64,
171 "max_input_size": 134217728u64,
172 "allow_multi_entry": true
173 });
174 let cfg: TarConfig = serde_json::from_value(json).unwrap();
175 assert_eq!(cfg.max_decompressed_size, 2147483648);
176 assert_eq!(cfg.max_input_size, 134217728);
177 assert!(cfg.allow_multi_entry);
178 }
179
180 #[test]
181 fn test_tar_config_deny_unknown_fields() {
182 let json = serde_json::json!({"unknown_key": 42});
183 let result: Result<TarConfig, _> = serde_json::from_value(json);
184 assert!(result.is_err());
185 }
186
187 #[test]
188 fn tar_round_trip_bytes() {
189 let df = TarDataFormat::default();
190 let original = Body::Bytes(Bytes::from_static(b"\x00\x01\x02\xffbinary payload"));
191 let archived = df.marshal(original.clone()).unwrap();
192 let archived_bytes = match &archived {
193 Body::Bytes(b) => b.clone(),
194 _ => panic!("expected Body::Bytes"),
195 };
196 let mut names = Vec::new();
197 for entry in tar::Archive::new(std::io::Cursor::new(&archived_bytes[..]))
198 .entries()
199 .unwrap()
200 {
201 names.push(entry.unwrap().path().unwrap().to_path_buf());
202 }
203 assert_eq!(names.len(), 1, "marshal must write exactly one entry");
204 assert_eq!(names[0], std::path::Path::new(ENTRY_NAME));
205 let restored = df.unmarshal(archived).unwrap();
206 assert_eq!(restored, original);
207 }
208
209 #[test]
210 fn tar_regular_entry_policy() {
211 let df_strict = TarDataFormat::default();
212 let df_multi = TarDataFormat::new(TarConfig {
213 allow_multi_entry: true,
214 ..Default::default()
215 });
216
217 let no_regular = make_tar(&[("only-dir/", DIRECTORY, b""), ("only-link", SYMLINK, b"")]);
220 for df in [&df_strict, &df_multi] {
221 let result = df.unmarshal(Body::Bytes(Bytes::from(no_regular.clone())));
222 match result {
223 Err(CamelError::TypeConversionFailed(_)) => {}
224 _ => panic!("archive without regular files must be rejected"),
225 }
226 }
227
228 let one_regular = make_tar(&[
231 ("dir/", DIRECTORY, b""),
232 ("payload.txt", REGULAR, b"single payload"),
233 ("link", HARDLINK, b""),
234 ]);
235 for df in [&df_strict, &df_multi] {
236 let restored = df
237 .unmarshal(Body::Bytes(Bytes::from(one_regular.clone())))
238 .unwrap();
239 assert_bytes(restored, b"single payload");
240 }
241
242 let two_regular = make_tar(&[
245 ("first.txt", REGULAR, b"first"),
246 ("dir/", DIRECTORY, b""),
247 ("second.txt", REGULAR, b"second"),
248 ]);
249 let err = df_strict
250 .unmarshal(Body::Bytes(Bytes::from(two_regular)))
251 .unwrap_err();
252 let msg = format!("{err}");
253 assert!(
254 msg.contains("allow_multi_entry"),
255 "error should mention allow_multi_entry: {msg}"
256 );
257
258 let two_regular = make_tar(&[
259 ("first.txt", REGULAR, b"first"),
260 ("second.txt", REGULAR, b"second"),
261 ]);
262 let (result, warnings) =
263 capture_warns(|| df_multi.unmarshal(Body::Bytes(Bytes::from(two_regular.clone()))));
264 assert_bytes(result.unwrap(), b"first");
265 assert!(
266 warnings.iter().any(|w| w.contains("multiple regular")),
267 "multi-entry path should warn, captured: {warnings:?}"
268 );
269 }
270
271 #[test]
272 fn tar_non_regular_entries_and_malicious_paths_are_ignored() {
273 let tar_bytes = make_tar(&[
274 ("../escape-dir/", DIRECTORY, b""),
275 ("/etc/passwd", SYMLINK, b""),
276 ("../escape-hardlink", HARDLINK, b""),
277 ("/dev/tty", CHARACTER_DEVICE, b""),
278 ("payload.txt", REGULAR, b"safe payload bytes"),
279 ]);
280 let df = TarDataFormat::default();
281 let restored = df.unmarshal(Body::Bytes(Bytes::from(tar_bytes))).unwrap();
285 assert_bytes(restored, b"safe payload bytes");
286 }
287
288 #[test]
289 fn malformed_tar_input_rejected() {
290 let df = TarDataFormat::default();
291
292 let garbage = vec![b'G'; 1024];
294 let result = df.unmarshal(Body::Bytes(Bytes::from(garbage)));
295 match result {
296 Err(CamelError::TypeConversionFailed(_)) => {}
297 _ => panic!("garbage input must yield TypeConversionFailed"),
298 }
299
300 let full = make_tar_payload(b"hello world");
302 let truncated = full[..400].to_vec();
303 let result = df.unmarshal(Body::Bytes(Bytes::from(truncated)));
304 match result {
305 Err(CamelError::TypeConversionFailed(_)) => {}
306 _ => panic!("truncated header must yield TypeConversionFailed"),
307 }
308 }
309
310 #[test]
311 fn tar_materialized_empty_and_stream_bodies() {
312 let df = TarDataFormat::default();
313
314 for body in [Body::Bytes(Bytes::new()), Body::Text(String::new())] {
318 let archived = df.marshal(body).unwrap();
319 let restored = df.unmarshal(archived).unwrap();
320 assert_bytes(restored, b"");
321 }
322
323 assert!(df.marshal(Body::Empty).is_err());
325
326 let (body, slot) = stream_body_pair();
329 assert!(df.marshal(body).is_err());
330 assert!(
331 slot.blocking_lock().is_some(),
332 "marshal must not consume the stream"
333 );
334
335 let (body, slot) = stream_body_pair();
336 assert!(df.unmarshal(body).is_err());
337 assert!(
338 slot.blocking_lock().is_some(),
339 "unmarshal must not consume the stream"
340 );
341 }
342
343 #[test]
344 fn test_marshal_input_size_cap() {
345 let config = TarConfig {
346 max_input_size: 16,
347 ..Default::default()
348 };
349 let df = TarDataFormat::new(config);
350 let result = df.marshal(Body::Text("x".repeat(64)));
351 assert!(result.is_err());
352 let msg = format!("{}", result.unwrap_err());
353 assert!(
354 msg.contains("max_input_size"),
355 "error should mention max_input_size: {msg}"
356 );
357 }
358
359 #[test]
360 fn test_max_decompressed_size_exceeded() {
361 let config = TarConfig {
362 max_decompressed_size: 10,
363 ..Default::default()
364 };
365 let df = TarDataFormat::new(config);
366 let tar_data = make_tar_payload(b"this content is way longer than 10 bytes");
367 let result = df.unmarshal(Body::Bytes(Bytes::from(tar_data)));
368 assert!(result.is_err());
369 }
370}