1use std::collections::{HashMap, HashSet};
13use std::io::Read;
14use std::path::Path;
15use std::pin::Pin;
16use std::sync::Arc;
17
18use bytes::Bytes;
19use futures::Stream;
20use serde::Deserialize;
21use tokio::sync::mpsc;
22
23use camel_api::{Body, CamelError, Exchange, Message, StreamingSplitExpression, Value};
24
25use crate::archive_splitter::{
26 DEFAULT_MAX_PATH_LENGTH, DuplicatePolicy, next_free_indexed_name, validate_entry_path,
27};
28use crate::data_format::gzip::decode_first_member;
29
30pub const CAMEL_TAR_ENTRY_NAME: &str = "CamelTarEntryName";
31pub const CAMEL_TAR_ENTRY_PATH: &str = "CamelTarEntryPath";
32pub const CAMEL_TAR_ENTRY_INDEX: &str = "CamelTarEntryIndex";
33pub const CAMEL_TAR_ENTRY_SIZE: &str = "CamelTarEntrySize";
34pub const CAMEL_TAR_ENTRY_IS_DIRECTORY: &str = "CamelTarEntryIsDirectory";
35
36const DEFAULT_MAX_ENTRIES: usize = 10_000;
37const DEFAULT_MAX_TOTAL_DECODED_SIZE: u64 = 1_073_741_824;
38const DEFAULT_MAX_PER_ENTRY_SIZE: u64 = 512 * 1024 * 1024;
39const DEFAULT_MAX_COMPRESSED_SIZE: u64 = 1_073_741_824;
40const DEFAULT_CHANNEL_CAPACITY: usize = 2;
41
42#[derive(Clone, Debug, Deserialize)]
48#[serde(deny_unknown_fields, default)]
49pub struct TarSplitConfig {
50 pub max_entries: usize,
52 pub max_total_decoded_size: u64,
57 pub max_per_entry_size: u64,
59 pub max_compressed_size: u64,
61 pub max_path_length: usize,
63 pub duplicate_names_policy: DuplicatePolicy,
69 pub allow_empty_archive: bool,
71}
72
73impl Default for TarSplitConfig {
74 fn default() -> Self {
75 Self {
76 max_entries: DEFAULT_MAX_ENTRIES,
77 max_total_decoded_size: DEFAULT_MAX_TOTAL_DECODED_SIZE,
78 max_per_entry_size: DEFAULT_MAX_PER_ENTRY_SIZE,
79 max_compressed_size: DEFAULT_MAX_COMPRESSED_SIZE,
80 max_path_length: DEFAULT_MAX_PATH_LENGTH,
81 duplicate_names_policy: DuplicatePolicy::default(),
82 allow_empty_archive: false,
83 }
84 }
85}
86
87fn validate_tar_entry_path(name: &str, max_length: usize) -> Result<String, CamelError> {
91 validate_entry_path(name, max_length, "TAR")
92}
93
94fn err_malformed_archive(detail: &str) -> CamelError {
96 CamelError::TypeConversionFailed(format!("Invalid TAR archive: {detail}"))
97}
98
99fn err_duplicate_entry_name(name: &str) -> CamelError {
101 CamelError::TypeConversionFailed(format!("Duplicate TAR entry name: {name}"))
102}
103
104fn err_max_entries(limit: usize) -> CamelError {
106 CamelError::TypeConversionFailed(format!("TAR exceeds max entries: {limit}"))
107}
108
109fn err_entry_too_large(name: &str, size: u64, limit: u64) -> CamelError {
111 CamelError::TypeConversionFailed(format!(
112 "TAR entry '{name}' size {size} exceeds max {limit}"
113 ))
114}
115
116fn err_total_decoded_exceeded(limit: u64) -> CamelError {
118 CamelError::TypeConversionFailed(format!("TAR total decoded size exceeds max {limit}"))
119}
120
121fn err_decoded_archive_budget(limit: u64) -> CamelError {
125 CamelError::TypeConversionFailed(format!(
126 "TAR.GZ decoded archive stream exceeds bounded decode budget {limit}"
127 ))
128}
129
130const MAX_TAR_FRAMING_ALLOWANCE: u64 = 64 * 1024 * 1024;
138
139const BASE_TAR_FRAMING_ALLOWANCE: u64 = 64 * 1024;
143
144const TAR_BLOCK_SIZE: u64 = 512;
146
147fn tar_per_entry_framing(path_cap: usize) -> u64 {
161 let extension_data = (path_cap as u64 + 32).div_ceil(TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
162 TAR_BLOCK_SIZE + TAR_BLOCK_SIZE + extension_data + (TAR_BLOCK_SIZE - 1)
163}
164
165fn tar_gz_decode_budget(config: &TarSplitConfig) -> u64 {
183 let per_entry =
184 (config.max_entries as u64).saturating_mul(tar_per_entry_framing(config.max_path_length));
185 let framing = per_entry.min(MAX_TAR_FRAMING_ALLOWANCE) + BASE_TAR_FRAMING_ALLOWANCE;
186 config.max_total_decoded_size.saturating_add(framing)
187}
188
189fn err_compressed_input_exceeded(size: u64, limit: u64) -> CamelError {
191 CamelError::TypeConversionFailed(format!("TAR compressed size {size} exceeds max {limit}"))
192}
193
194fn err_multi_member_gzip() -> CamelError {
197 CamelError::TypeConversionFailed(
198 "TAR.GZ input contains multiple GZIP members; only a single-member \
199 GZIP stream is supported"
200 .to_string(),
201 )
202}
203
204fn err_empty_archive() -> CamelError {
206 CamelError::TypeConversionFailed(
207 "TAR archive contains no regular entries; enable allow_empty_archive \
208 to accept it"
209 .to_string(),
210 )
211}
212
213struct TarEntryData {
216 index: usize,
217 path: String,
218 size: u64,
219 data: Vec<u8>,
220}
221
222pub fn split_tar_bytes(
234 parent: Exchange,
235 bytes: Bytes,
236 config: TarSplitConfig,
237) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>> {
238 Box::pin(async_stream::stream! {
239 if bytes.len() as u64 > config.max_compressed_size {
242 yield Err(err_compressed_input_exceeded(
243 bytes.len() as u64,
244 config.max_compressed_size,
245 ));
246 return;
247 }
248
249 let entries = tar_entry_stream(parent, bytes, config);
250 for await result in entries {
251 yield result;
252 }
253 })
254}
255
256pub fn split_tar_gz_bytes(
269 parent: Exchange,
270 bytes: Bytes,
271 config: TarSplitConfig,
272) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>> {
273 Box::pin(async_stream::stream! {
274 if bytes.len() as u64 > config.max_compressed_size {
277 yield Err(err_compressed_input_exceeded(
278 bytes.len() as u64,
279 config.max_compressed_size,
280 ));
281 return;
282 }
283
284 let decode_budget = tar_gz_decode_budget(&config);
293 let take_limit = decode_budget.saturating_add(1);
294 let decode_input = bytes.clone();
295 let first = match tokio::task::spawn_blocking(move || {
296 decode_first_member(&decode_input, take_limit)
297 })
298 .await
299 {
300 Ok(Ok(first)) => first,
301 Ok(Err(e)) => {
302 yield Err(err_malformed_archive(&format!(
303 "failed to decode GZIP stream: {e}"
304 )));
305 return;
306 }
307 Err(e) => {
308 yield Err(err_malformed_archive(&format!(
309 "GZIP decode task failed: {e}"
310 )));
311 return;
312 }
313 };
314
315 if first.data.len() as u64 >= take_limit {
319 yield Err(err_decoded_archive_budget(decode_budget));
320 return;
321 }
322
323 if first.has_trailing_input {
326 yield Err(err_multi_member_gzip());
327 return;
328 }
329
330 let entries = tar_entry_stream(parent, Bytes::from(first.data), config);
331 for await result in entries {
332 yield result;
333 }
334 })
335}
336
337fn tar_entry_stream(
341 parent: Exchange,
342 bytes: Bytes,
343 config: TarSplitConfig,
344) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>> {
345 Box::pin(async_stream::stream! {
346 let (tx, mut rx) = mpsc::channel::<Result<TarEntryData, CamelError>>(
347 DEFAULT_CHANNEL_CAPACITY,
348 );
349
350 let max_entries = config.max_entries;
351 let max_per_entry = config.max_per_entry_size;
352 let max_total = config.max_total_decoded_size;
353 let max_path_len = config.max_path_length;
354 let allow_empty = config.allow_empty_archive;
355 let dup_policy = config.duplicate_names_policy;
356
357 tokio::task::spawn_blocking(move || {
362 let mut archive = tar::Archive::new(std::io::Cursor::new(bytes));
363 let entries = match archive.entries() {
364 Ok(entries) => entries,
365 Err(e) => {
366 let _ = tx.blocking_send(Err(err_malformed_archive(&e.to_string())));
367 return;
368 }
369 };
370
371 let mut total_decoded: u64 = 0;
372 let mut emitted: usize = 0;
373 let mut emitted_names: HashSet<String> = HashSet::new();
374 let mut name_occurrences: HashMap<String, usize> = HashMap::new();
375
376 for entry in entries {
377 let mut entry = match entry {
378 Ok(e) => e,
379 Err(e) => {
380 let _ = tx.blocking_send(Err(err_malformed_archive(&e.to_string())));
381 return;
382 }
383 };
384
385 let raw_name = match entry.path() {
388 Ok(p) => match p.to_str() {
389 Some(name) => name.to_string(),
390 None => {
391 let _ = tx.blocking_send(Err(err_malformed_archive(
392 "entry name is not valid UTF-8",
393 )));
394 return;
395 }
396 },
397 Err(e) => {
398 let _ = tx.blocking_send(Err(err_malformed_archive(&e.to_string())));
399 return;
400 }
401 };
402 let mut validated = match validate_tar_entry_path(&raw_name, max_path_len) {
403 Ok(path) => path,
404 Err(e) => {
405 let _ = tx.blocking_send(Err(e));
406 return;
407 }
408 };
409
410 if !matches!(entry.header().entry_type(), tar::EntryType::Regular) {
414 continue;
415 }
416
417 let mut data = Vec::new();
420 let mut limited = Read::take(&mut entry, max_per_entry.saturating_add(1));
421 if let Err(e) = limited.read_to_end(&mut data) {
422 let _ = tx.blocking_send(Err(err_malformed_archive(&format!(
423 "failed to read TAR entry '{raw_name}': {e}"
424 ))));
425 return;
426 }
427
428 if data.len() as u64 > max_per_entry {
429 let declared = entry.header().size().unwrap_or(data.len() as u64);
432 let _ = tx.blocking_send(Err(err_entry_too_large(
433 &raw_name,
434 declared,
435 max_per_entry,
436 )));
437 return;
438 }
439
440 let new_total = total_decoded.saturating_add(data.len() as u64);
441 if new_total > max_total {
442 let _ = tx.blocking_send(Err(err_total_decoded_exceeded(max_total)));
443 return;
444 }
445 total_decoded = new_total;
446
447 let index = emitted;
448 if index >= max_entries {
449 let _ = tx.blocking_send(Err(err_max_entries(max_entries)));
450 return;
451 }
452 emitted += 1;
453
454 match dup_policy {
455 DuplicatePolicy::Reject => {
456 if !emitted_names.insert(validated.clone()) {
457 let _ = tx.blocking_send(Err(err_duplicate_entry_name(&validated)));
458 return;
459 }
460 }
461 DuplicatePolicy::AllowWithIndex => {
462 let occurrences =
469 name_occurrences.entry(validated.clone()).or_insert(0);
470 let start = if *occurrences > 0 {
471 *occurrences
472 } else if emitted_names.contains(&validated) {
473 1
474 } else {
475 0
476 };
477 if start > 0 {
478 let (candidate, used) =
479 next_free_indexed_name(&validated, start, &emitted_names);
480 validated = match validate_tar_entry_path(&candidate, max_path_len)
483 {
484 Ok(path) => path,
485 Err(e) => {
486 let _ = tx.blocking_send(Err(e));
487 return;
488 }
489 };
490 *occurrences = (*occurrences).max(used + 1);
491 }
492 emitted_names.insert(validated.clone());
493 }
494 }
495
496 if tx
497 .blocking_send(Ok(TarEntryData {
498 index,
499 path: validated,
500 size: data.len() as u64,
501 data,
502 }))
503 .is_err()
504 {
505 return;
506 }
507 }
508
509 if emitted == 0 && !allow_empty {
510 let _ = tx.blocking_send(Err(err_empty_archive()));
511 }
512 });
513
514 while let Some(result) = rx.recv().await {
515 match result {
516 Ok(entry) => {
517 let TarEntryData {
518 index,
519 path,
520 size,
521 data,
522 } = entry;
523 let msg = Message {
524 headers: parent.input.headers.clone(),
525 body: Body::Bytes(Bytes::from(data)),
526 };
527 let mut ex = Exchange::new(msg);
528 ex.input.headers.remove("Content-Length");
531 ex.input.headers.remove("Content-Type");
532 ex.properties = parent.properties.clone();
533 ex.pattern = parent.pattern;
534 ex.otel_context = parent.otel_context.clone();
535
536 let entry_name = Path::new(&path)
537 .file_name()
538 .map(|n| n.to_string_lossy().to_string())
539 .unwrap_or_default();
540
541 ex.input.headers.insert(
542 CAMEL_TAR_ENTRY_NAME.to_string(),
543 Value::String(entry_name),
544 );
545 ex.input
546 .headers
547 .insert(CAMEL_TAR_ENTRY_PATH.to_string(), Value::String(path));
548 ex.input.headers.insert(
549 CAMEL_TAR_ENTRY_INDEX.to_string(),
550 Value::from(index as u64),
551 );
552 ex.input
553 .headers
554 .insert(CAMEL_TAR_ENTRY_SIZE.to_string(), Value::from(size));
555 ex.input.headers.insert(
556 CAMEL_TAR_ENTRY_IS_DIRECTORY.to_string(),
557 Value::Bool(false),
558 );
559
560 yield Ok(ex);
561 }
562 Err(e) => {
563 yield Err(e);
564 }
565 }
566 }
567 })
568}
569
570pub fn tar_splitter(config: TarSplitConfig) -> StreamingSplitExpression {
573 Arc::new(move |exchange: Exchange| {
574 let config = config.clone();
575 match exchange.input.body.clone() {
576 Body::Bytes(b) => split_tar_bytes(exchange, b, config),
577 Body::Text(s) => split_tar_bytes(exchange, Bytes::from(s.as_bytes().to_vec()), config),
578 _ => Box::pin(async_stream::stream! {
579 yield Err(CamelError::TypeConversionFailed(
580 "TarSplitter requires Body::Bytes or Body::Text".to_string(),
581 ));
582 }),
583 }
584 })
585}
586
587pub fn tar_gz_splitter(config: TarSplitConfig) -> StreamingSplitExpression {
590 Arc::new(move |exchange: Exchange| {
591 let config = config.clone();
592 match exchange.input.body.clone() {
593 Body::Bytes(b) => split_tar_gz_bytes(exchange, b, config),
594 Body::Text(s) => {
595 split_tar_gz_bytes(exchange, Bytes::from(s.as_bytes().to_vec()), config)
596 }
597 _ => Box::pin(async_stream::stream! {
598 yield Err(CamelError::TypeConversionFailed(
599 "TarGzSplitter requires Body::Bytes or Body::Text".to_string(),
600 ));
601 }),
602 }
603 })
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use crate::archive_splitter::test_util::make_zip_raw;
610 use crate::zip_splitter::{CAMEL_ZIP_ENTRY_PATH, ZipSplitConfig, zip_splitter};
611 use futures::StreamExt;
612
613 fn tar_header(name: &str, size: u64, typeflag: u8) -> [u8; 512] {
618 let mut h = [0u8; 512];
619 h[..name.len()].copy_from_slice(name.as_bytes());
620 h[124..136].copy_from_slice(format!("{size:011o}\0").as_bytes());
621 h[148..156].copy_from_slice(b" ");
623 h[156] = typeflag;
624 h[257..263].copy_from_slice(b"ustar\0");
625 h[263..265].copy_from_slice(b"00");
626 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
627 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
628 h
629 }
630
631 fn tar_archive(entries: &[(&str, u8, &[u8])]) -> Vec<u8> {
634 let mut out = Vec::new();
635 for &(name, typeflag, data) in entries {
636 out.extend_from_slice(&tar_header(name, data.len() as u64, typeflag));
637 if !data.is_empty() {
638 let mut block = data.to_vec();
639 let rem = block.len() % 512;
640 if rem != 0 {
641 block.extend_from_slice(&vec![0u8; 512 - rem]);
642 }
643 out.extend_from_slice(&block);
644 }
645 }
646 out.extend_from_slice(&[0u8; 1024]);
647 out
648 }
649
650 async fn collect(
651 config: TarSplitConfig,
652 tar_data: Vec<u8>,
653 ) -> Vec<Result<Exchange, CamelError>> {
654 let expr = tar_splitter(config);
655 let exchange = Exchange::new(Message {
656 headers: Default::default(),
657 body: Body::Bytes(Bytes::from(tar_data)),
658 });
659 expr(exchange).collect().await
660 }
661
662 #[test]
663 fn tar_split_config_rejects_unknown_fields() {
664 let cfg: TarSplitConfig =
666 serde_json::from_str(r#"{"max_entries": 5, "allow_empty_archive": true}"#)
667 .expect("valid config must deserialize");
668 assert_eq!(cfg.max_entries, 5);
669 assert!(cfg.allow_empty_archive);
670 assert_eq!(cfg.max_per_entry_size, DEFAULT_MAX_PER_ENTRY_SIZE);
671 assert_eq!(cfg.max_path_length, DEFAULT_MAX_PATH_LENGTH);
672
673 let err = serde_json::from_str::<TarSplitConfig>(r#"{"unknown_key": 1}"#)
675 .expect_err("unknown config key must fail");
676 assert!(
677 err.to_string().contains("unknown field `unknown_key`"),
678 "{err}"
679 );
680 }
681
682 #[tokio::test]
683 async fn tar_split_emits_regular_files_in_header_order() {
684 let tar_data = tar_archive(&[
687 ("first.txt", b'0', b"alpha".as_slice()),
688 ("docs", b'5', b""),
689 ("link.txt", b'2', b""),
690 ("hard.txt", b'1', b""),
691 ("dev-zero", b'3', b""),
692 ("second.txt", b'0', b"beta".as_slice()),
693 ]);
694 let results = collect(TarSplitConfig::default(), tar_data).await;
695 assert_eq!(results.len(), 2, "non-regular entries must be omitted");
696
697 let first = results[0].as_ref().expect("first fragment ok");
698 let second = results[1].as_ref().expect("second fragment ok");
699
700 assert_eq!(
701 first.input.headers.get(CAMEL_TAR_ENTRY_NAME),
702 Some(&Value::String("first.txt".to_string()))
703 );
704 assert_eq!(
705 second.input.headers.get(CAMEL_TAR_ENTRY_NAME),
706 Some(&Value::String("second.txt".to_string()))
707 );
708 assert_eq!(
709 first.input.headers.get(CAMEL_TAR_ENTRY_INDEX),
710 Some(&Value::from(0u64))
711 );
712 assert_eq!(
713 second.input.headers.get(CAMEL_TAR_ENTRY_INDEX),
714 Some(&Value::from(1u64))
715 );
716 match &first.input.body {
717 Body::Bytes(b) => assert_eq!(b.as_ref(), b"alpha"),
718 other => panic!("expected Body::Bytes, got {other:?}"),
719 }
720 match &second.input.body {
721 Body::Bytes(b) => assert_eq!(b.as_ref(), b"beta"),
722 other => panic!("expected Body::Bytes, got {other:?}"),
723 }
724 assert_eq!(
725 first.input.headers.get(CAMEL_TAR_ENTRY_PATH),
726 Some(&Value::String("first.txt".to_string()))
727 );
728 assert_eq!(
729 first.input.headers.get(CAMEL_TAR_ENTRY_SIZE),
730 Some(&Value::from(5u64))
731 );
732 assert_eq!(
733 first.input.headers.get(CAMEL_TAR_ENTRY_IS_DIRECTORY),
734 Some(&Value::Bool(false))
735 );
736 }
737
738 #[tokio::test]
739 async fn tar_split_rejects_traversal_and_absolute_names() {
740 let cases = [
741 (
742 "../escape",
743 "TAR entry path contains '..' traversal: ../escape",
744 ),
745 ("/absolute", "TAR entry path is absolute: /absolute"),
746 ];
747 for (name, expected) in cases {
748 let tar_data = tar_archive(&[(name, b'0', b"oops".as_slice())]);
749 let results = collect(TarSplitConfig::default(), tar_data).await;
750 assert_eq!(
751 results.len(),
752 1,
753 "expected exactly the validation error for {name}"
754 );
755 let err = results[0]
756 .as_ref()
757 .expect_err(&format!("'{name}' must be rejected"))
758 .to_string();
759 assert!(
760 err.contains(expected),
761 "error text mismatch for {name}: {err}"
762 );
763 }
764 }
765
766 #[tokio::test]
767 async fn tar_split_enforces_all_bounds() {
768 let tar_data = tar_archive(&[
770 ("a.txt", b'0', b"1".as_slice()),
771 ("b.txt", b'0', b"2".as_slice()),
772 ("c.txt", b'0', b"3".as_slice()),
773 ]);
774 let config = TarSplitConfig {
775 max_entries: 2,
776 ..Default::default()
777 };
778 let results = collect(config, tar_data).await;
779 assert!(
780 results.iter().any(|r| r
781 .as_ref()
782 .is_err_and(|e| e.to_string().contains("TAR exceeds max entries: 2"))),
783 "expected entry-count cap error, got {results:?}"
784 );
785
786 let tar_data = tar_archive(&[("big.bin", b'0', &[b'x'; 200])]);
788 let config = TarSplitConfig {
789 max_per_entry_size: 100,
790 ..Default::default()
791 };
792 let results = collect(config, tar_data).await;
793 assert_eq!(results.len(), 1);
794 assert!(
795 results[0]
796 .as_ref()
797 .expect_err("per-entry cap")
798 .to_string()
799 .contains("TAR entry 'big.bin' size 200 exceeds max 100"),
800 "expected per-entry cap error"
801 );
802
803 let tar_data = tar_archive(&[
805 ("a.txt", b'0', b"0123456789".as_slice()),
806 ("b.txt", b'0', b"9876543210".as_slice()),
807 ]);
808 let config = TarSplitConfig {
809 max_total_decoded_size: 15,
810 ..Default::default()
811 };
812 let results = collect(config, tar_data).await;
813 assert!(
814 results.iter().any(|r| r.as_ref().is_err_and(|e| e
815 .to_string()
816 .contains("TAR total decoded size exceeds max 15"))),
817 "expected total-decoded cap error, got {results:?}"
818 );
819
820 let tar_data = tar_archive(&[("a.txt", b'0', b"x".as_slice())]);
822 let config = TarSplitConfig {
823 max_compressed_size: 512,
824 ..Default::default()
825 };
826 let results = collect(config, tar_data).await;
827 assert_eq!(results.len(), 1);
828 assert!(
829 results[0]
830 .as_ref()
831 .expect_err("compressed-input cap")
832 .to_string()
833 .contains("exceeds max 512"),
834 "expected compressed-input cap error"
835 );
836
837 let long_name = "a-very-long-entry-name.bin";
839 let tar_data = tar_archive(&[(long_name, b'0', b"x".as_slice())]);
840 let config = TarSplitConfig {
841 max_path_length: 10,
842 ..Default::default()
843 };
844 let results = collect(config, tar_data).await;
845 assert_eq!(results.len(), 1);
846 assert!(
847 results[0]
848 .as_ref()
849 .expect_err("path-length cap")
850 .to_string()
851 .contains(&format!(
852 "TAR entry path exceeds max length: {} > 10",
853 long_name.len()
854 )),
855 "expected path-length cap error"
856 );
857 }
858
859 #[tokio::test]
860 async fn tar_split_empty_and_directory_only_archives_emit_zero() {
861 let config = TarSplitConfig {
862 allow_empty_archive: true,
863 ..Default::default()
864 };
865 let results = collect(config.clone(), tar_archive(&[])).await;
866 assert!(
867 results.is_empty(),
868 "empty archive must emit zero fragments: {results:?}"
869 );
870
871 let results = collect(
872 config,
873 tar_archive(&[("only-dir", b'5', b""), ("nested", b'5', b"")]),
874 )
875 .await;
876 assert!(
877 results.is_empty(),
878 "directory-only archive must emit zero fragments: {results:?}"
879 );
880 }
881
882 fn gzip_bytes(raw: &[u8]) -> Vec<u8> {
884 use std::io::Write as _;
885 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
886 encoder.write_all(raw).expect("gzip write");
887 encoder.finish().expect("gzip finish")
888 }
889
890 async fn collect_gz(
891 config: TarSplitConfig,
892 gz_data: Vec<u8>,
893 ) -> Vec<Result<Exchange, CamelError>> {
894 let expr = tar_gz_splitter(config);
895 let exchange = Exchange::new(Message {
896 headers: Default::default(),
897 body: Body::Bytes(Bytes::from(gz_data)),
898 });
899 expr(exchange).collect().await
900 }
901
902 fn header_string(exchange: &Exchange, key: &str) -> String {
903 match exchange.input.headers.get(key) {
904 Some(Value::String(s)) => s.clone(),
905 other => panic!("header {key} must be a string, got {other:?}"),
906 }
907 }
908
909 fn body_bytes(exchange: &Exchange) -> Vec<u8> {
910 match &exchange.input.body {
911 Body::Bytes(b) => b.to_vec(),
912 other => panic!("expected Body::Bytes, got {other:?}"),
913 }
914 }
915
916 #[tokio::test]
917 async fn tar_gz_split_matches_tar_metadata() {
918 let tar_data = tar_archive(&[
919 ("first.txt", b'0', b"alpha".as_slice()),
920 ("docs", b'5', b""),
921 ("second.txt", b'0', b"beta".as_slice()),
922 ]);
923
924 let tar_results = collect(TarSplitConfig::default(), tar_data.clone()).await;
925 let gz_results = collect_gz(TarSplitConfig::default(), gzip_bytes(&tar_data)).await;
926
927 assert_eq!(gz_results.len(), tar_results.len(), "same entries emitted");
928 for (t, g) in tar_results.iter().zip(gz_results.iter()) {
929 let t = t.as_ref().expect("TAR fragment ok");
930 let g = g.as_ref().expect("TAR.GZ fragment ok");
931 for header in [
932 CAMEL_TAR_ENTRY_NAME,
933 CAMEL_TAR_ENTRY_PATH,
934 CAMEL_TAR_ENTRY_INDEX,
935 CAMEL_TAR_ENTRY_SIZE,
936 CAMEL_TAR_ENTRY_IS_DIRECTORY,
937 ] {
938 assert_eq!(
939 t.input.headers.get(header),
940 g.input.headers.get(header),
941 "header {header} must match TAR output"
942 );
943 }
944 assert_eq!(t.input.body, g.input.body, "body must match TAR output");
945 }
946 }
947
948 #[tokio::test]
954 async fn tar_split_applies_shared_duplicate_policy() {
955 let tar_data = tar_archive(&[
956 ("dup.txt", b'0', b"one".as_slice()),
957 ("other.txt", b'0', b"mid".as_slice()),
958 ("dup.txt", b'0', b"two".as_slice()),
959 ]);
960
961 let reject_config = TarSplitConfig {
963 duplicate_names_policy: DuplicatePolicy::Reject,
964 ..Default::default()
965 };
966 let results = collect(reject_config, tar_data.clone()).await;
967 assert!(
968 results.iter().any(|r| r
969 .as_ref()
970 .is_err_and(|e| e.to_string().contains("Duplicate TAR entry name: dup.txt"))),
971 "reject policy must fail on the duplicate: {results:?}"
972 );
973
974 let index_config = TarSplitConfig {
977 duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
978 ..Default::default()
979 };
980 let first_run = collect(index_config.clone(), tar_data.clone()).await;
981 let second_run = collect(index_config, tar_data).await;
982
983 assert_eq!(first_run.len(), 3, "every entry must be emitted");
984 let snapshot: Vec<(String, String, Vec<u8>)> = first_run
985 .iter()
986 .map(|r| {
987 let ex = r.as_ref().expect("allow-with-index must not fail");
988 (
989 header_string(ex, CAMEL_TAR_ENTRY_PATH),
990 header_string(ex, CAMEL_TAR_ENTRY_NAME),
991 body_bytes(ex),
992 )
993 })
994 .collect();
995 assert_eq!(
996 snapshot,
997 [
998 (
999 "dup.txt".to_string(),
1000 "dup.txt".to_string(),
1001 b"one".to_vec()
1002 ),
1003 (
1004 "other.txt".to_string(),
1005 "other.txt".to_string(),
1006 b"mid".to_vec()
1007 ),
1008 (
1009 "dup.1.txt".to_string(),
1010 "dup.1.txt".to_string(),
1011 b"two".to_vec()
1012 ),
1013 ],
1014 "indexed names must be deterministic"
1015 );
1016
1017 let second_paths: Vec<String> = second_run
1018 .iter()
1019 .map(|r| {
1020 let ex = r.as_ref().expect("second run must not fail");
1021 header_string(ex, CAMEL_TAR_ENTRY_PATH)
1022 })
1023 .collect();
1024 let first_paths: Vec<String> = snapshot.into_iter().map(|(p, _, _)| p).collect();
1025 assert_eq!(
1026 first_paths, second_paths,
1027 "naming must be deterministic across runs"
1028 );
1029 }
1030
1031 #[tokio::test]
1044 async fn tar_duplicate_policy_is_shared_zip_collapse_is_pinned() {
1045 let tar_data = tar_archive(&[
1046 ("dup.txt", b'0', b"one".as_slice()),
1047 ("dup.txt", b'0', b"two".as_slice()),
1048 ]);
1049 let zip_data = make_zip_raw(&[
1050 ("dup.txt", b"one".as_slice()),
1051 ("dup.txt", b"two".as_slice()),
1052 ]);
1053
1054 let index_config = TarSplitConfig {
1057 duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1058 ..Default::default()
1059 };
1060 let tar_results = collect(index_config, tar_data.clone()).await;
1061 let tar_paths: Vec<String> = tar_results
1062 .iter()
1063 .map(|r| header_string(r.as_ref().expect("TAR fragment ok"), CAMEL_TAR_ENTRY_PATH))
1064 .collect();
1065 assert_eq!(
1066 tar_paths,
1067 ["dup.txt".to_string(), "dup.1.txt".to_string()],
1068 "TAR indexed names must come from the shared mangling helper"
1069 );
1070
1071 let zip_expr = zip_splitter(ZipSplitConfig {
1075 duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1076 ..Default::default()
1077 });
1078 let zip_exchange = Exchange::new(Message {
1079 headers: Default::default(),
1080 body: Body::Bytes(Bytes::from(zip_data.clone())),
1081 });
1082 let zip_results: Vec<Result<Exchange, CamelError>> = zip_expr(zip_exchange).collect().await;
1083 assert_eq!(
1084 zip_results.len(),
1085 1,
1086 "the zip reader must collapse duplicate names for now: {zip_results:?}"
1087 );
1088 let zip_path = header_string(
1089 zip_results[0].as_ref().expect("ZIP fragment ok"),
1090 CAMEL_ZIP_ENTRY_PATH,
1091 );
1092 assert_eq!(
1093 zip_path, "dup.txt",
1094 "unique observable names stay unmangled"
1095 );
1096
1097 let reject_config = TarSplitConfig {
1099 duplicate_names_policy: DuplicatePolicy::Reject,
1100 ..Default::default()
1101 };
1102 let tar_err = collect(reject_config, tar_data).await;
1103 let tar_msg = tar_err
1104 .iter()
1105 .find_map(|r| r.as_ref().err().map(|e| e.to_string()))
1106 .expect("TAR reject must fail");
1107 assert!(
1108 tar_msg.contains("Duplicate TAR entry name: dup.txt"),
1109 "{tar_msg}"
1110 );
1111
1112 let zip_expr = zip_splitter(ZipSplitConfig {
1116 duplicate_names_policy: DuplicatePolicy::Reject,
1117 ..Default::default()
1118 });
1119 let zip_exchange = Exchange::new(Message {
1120 headers: Default::default(),
1121 body: Body::Bytes(Bytes::from(zip_data)),
1122 });
1123 let zip_results: Vec<Result<Exchange, CamelError>> = zip_expr(zip_exchange).collect().await;
1124 assert!(
1125 zip_results.iter().all(|r| r.is_ok()),
1126 "collapsed names are unique, so reject must not fire: {zip_results:?}"
1127 );
1128 }
1129
1130 #[tokio::test]
1131 async fn tar_gz_compressed_input_limit_is_checked() {
1132 let gz = gzip_bytes(&tar_archive(&[("a.txt", b'0', b"payload".as_slice())]));
1133 let config = TarSplitConfig {
1134 max_compressed_size: gz.len() as u64 - 1,
1135 ..Default::default()
1136 };
1137 let results = collect_gz(config, gz.clone()).await;
1138 assert_eq!(results.len(), 1);
1139 let err = results[0]
1140 .as_ref()
1141 .expect_err("compressed input over the cap must fail before decompression")
1142 .to_string();
1143 assert!(
1144 err.contains(&format!(
1145 "TAR compressed size {} exceeds max {}",
1146 gz.len(),
1147 gz.len() - 1
1148 )),
1149 "expected the compressed-input cap error: {err}"
1150 );
1151 }
1152
1153 #[tokio::test]
1154 async fn tar_gz_multi_member_is_rejected() {
1155 let member_one = gzip_bytes(&tar_archive(&[("a.txt", b'0', b"one".as_slice())]));
1156 let member_two = gzip_bytes(&tar_archive(&[("b.txt", b'0', b"two".as_slice())]));
1157 let mut concatenated = member_one;
1158 concatenated.extend_from_slice(&member_two);
1159
1160 let results = collect_gz(TarSplitConfig::default(), concatenated).await;
1161 assert_eq!(results.len(), 1);
1162 let err = results[0]
1163 .as_ref()
1164 .expect_err("multi-member input must be rejected, not split silently")
1165 .to_string();
1166 assert!(
1167 err.contains("multiple GZIP members"),
1168 "expected the unsupported-multi-member error: {err}"
1169 );
1170 }
1171
1172 #[tokio::test]
1178 async fn tar_gz_total_decoded_cap_counts_payload_not_framing() {
1179 let payload: &[u8] = b"0123456789";
1183 let gz = gzip_bytes(&tar_archive(&[
1184 ("a.txt", b'0', payload),
1185 ("b.txt", b'0', payload),
1186 ("c.txt", b'0', payload),
1187 ]));
1188 let results = collect_gz(
1189 TarSplitConfig {
1190 max_total_decoded_size: 64,
1191 ..Default::default()
1192 },
1193 gz,
1194 )
1195 .await;
1196 assert_eq!(
1197 results.len(),
1198 3,
1199 "framing must not count against the payload cap"
1200 );
1201 for r in &results {
1202 assert!(r.is_ok(), "fragment must succeed: {r:?}");
1203 }
1204
1205 let big = vec![b'x'; 100];
1208 let gz_big = gzip_bytes(&tar_archive(&[("big.txt", b'0', big.as_slice())]));
1209 let over = collect_gz(
1210 TarSplitConfig {
1211 max_total_decoded_size: 64,
1212 ..Default::default()
1213 },
1214 gz_big,
1215 )
1216 .await;
1217 assert_eq!(over.len(), 1);
1218 let err = over[0]
1219 .as_ref()
1220 .expect_err("payload over the cap must fail at the parse")
1221 .to_string();
1222 assert!(
1223 err.contains("TAR total decoded size exceeds max 64"),
1224 "expected the payload-cap error: {err}"
1225 );
1226 }
1227
1228 #[test]
1232 fn tar_gz_decode_budget_is_fail_closed_under_absurd_entry_caps() {
1233 assert_eq!(tar_per_entry_framing(4096), 6143);
1237 assert_eq!(
1240 tar_gz_decode_budget(&TarSplitConfig::default()),
1241 1024 * 1024 * 1024 + 61_430_000 + 64 * 1024
1242 );
1243
1244 let config = TarSplitConfig {
1245 max_entries: usize::MAX,
1246 max_total_decoded_size: 1024,
1247 ..Default::default()
1248 };
1249 let budget = tar_gz_decode_budget(&config);
1250 assert_eq!(
1251 budget,
1252 1024 + MAX_TAR_FRAMING_ALLOWANCE + BASE_TAR_FRAMING_ALLOWANCE,
1253 "the entry-count term must hit the absolute ceiling, not saturate"
1254 );
1255 assert!(budget < u64::MAX, "the budget must stay fail-closed finite");
1256 }
1257
1258 #[tokio::test]
1263 async fn tar_gz_long_name_extension_blocks_stay_within_budget() {
1264 let long_path = format!("{}file.txt", "very/long/directory/prefix/".repeat(12));
1265 assert!(long_path.len() > 100, "the path must exceed the name field");
1266
1267 let mut builder = tar::Builder::new(Vec::new());
1270 let mut header = tar::Header::new_gnu();
1271 header.set_size(10);
1272 header.set_mode(0o644);
1273 header.set_cksum();
1274 builder
1275 .append_data(&mut header, long_path.as_str(), b"0123456789".as_slice())
1276 .expect("append GNU long-name entry");
1277 let gnu_archive = builder.into_inner().expect("finish GNU archive");
1278
1279 let pax_record = {
1282 let body = format!(" path={long_path}\n");
1283 let mut total = body.len() + 1;
1285 while total.to_string().len() + body.len() != total {
1286 total += 1;
1287 }
1288 format!("{total}{body}").into_bytes()
1289 };
1290 let pax_archive = tar_archive(&[
1291 ("./PaxHeaders.0/f", b'x', pax_record.as_slice()),
1292 ("file.txt", b'0', b"0123456789".as_slice()),
1293 ]);
1294
1295 for (label, archive) in [("gnu", gnu_archive), ("pax", pax_archive)] {
1296 let gz = gzip_bytes(&archive);
1297 let results = collect_gz(
1298 TarSplitConfig {
1299 max_total_decoded_size: 32,
1302 ..Default::default()
1303 },
1304 gz,
1305 )
1306 .await;
1307 assert_eq!(results.len(), 1, "{label}: one fragment expected");
1308 let ex = results[0]
1309 .as_ref()
1310 .unwrap_or_else(|e| panic!("{label}: long-name archive must split: {e}"));
1311 let path = header_string(ex, CAMEL_TAR_ENTRY_PATH);
1312 assert!(
1313 path.ends_with("file.txt") && path.len() > 100,
1314 "{label}: the long path must survive: {path}"
1315 );
1316 }
1317 }
1318
1319 #[tokio::test]
1324 async fn tar_split_indexed_names_never_collide_with_emitted() {
1325 let data = tar_archive(&[
1326 ("a.txt", b'0', b"first".as_slice()),
1327 ("a.1.txt", b'0', b"literal".as_slice()),
1328 ("a.txt", b'0', b"second".as_slice()),
1329 ]);
1330 let results = collect(
1331 TarSplitConfig {
1332 duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1333 ..Default::default()
1334 },
1335 data,
1336 )
1337 .await;
1338 let paths: Vec<String> = results
1339 .iter()
1340 .map(|r| header_string(r.as_ref().expect("fragment ok"), CAMEL_TAR_ENTRY_PATH))
1341 .collect();
1342 assert_eq!(
1343 paths,
1344 [
1345 "a.txt".to_string(),
1346 "a.1.txt".to_string(),
1347 "a.2.txt".to_string()
1348 ],
1349 "the second a.txt must skip the occupied a.1.txt"
1350 );
1351
1352 let data = tar_archive(&[
1356 ("a.txt", b'0', b"first".as_slice()),
1357 ("a.txt", b'0', b"second".as_slice()),
1358 ("a.1.txt", b'0', b"literal".as_slice()),
1359 ]);
1360 let results = collect(
1361 TarSplitConfig {
1362 duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1363 ..Default::default()
1364 },
1365 data,
1366 )
1367 .await;
1368 let paths: Vec<String> = results
1369 .iter()
1370 .map(|r| header_string(r.as_ref().expect("fragment ok"), CAMEL_TAR_ENTRY_PATH))
1371 .collect();
1372 assert_eq!(
1373 paths,
1374 [
1375 "a.txt".to_string(),
1376 "a.1.txt".to_string(),
1377 "a.1.1.txt".to_string()
1378 ],
1379 "the literal a.1.txt must skip the emitted indexed a.1.txt"
1380 );
1381 }
1382
1383 #[tokio::test]
1384 async fn tar_split_empty_default_is_rejected() {
1385 let results = collect(TarSplitConfig::default(), tar_archive(&[])).await;
1386 assert_eq!(results.len(), 1);
1387 let err = results[0]
1388 .as_ref()
1389 .expect_err("empty archive must fail closed by default")
1390 .to_string();
1391 assert!(
1392 err.contains("no regular entries") && err.contains("allow_empty_archive"),
1393 "expected the fail-closed empty-archive error: {err}"
1394 );
1395 }
1396}