Skip to main content

camel_processor/
zip_splitter.rs

1use bytes::Bytes;
2use std::collections::HashMap;
3use std::io::Read;
4use std::path::Path;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
8use tokio::sync::mpsc;
9
10use camel_api::{Body, CamelError, Exchange, Message, StreamingSplitExpression, Value};
11use futures::Stream;
12
13use crate::archive_splitter::{
14    DEFAULT_MAX_PATH_LENGTH, next_free_indexed_name, validate_entry_path,
15};
16
17/// Duplicate-name policy shared with the TAR splitter; re-exported to
18/// preserve the historical public path
19/// `camel_processor::zip_splitter::DuplicatePolicy`.
20pub use crate::archive_splitter::DuplicatePolicy;
21
22const DEFAULT_MAX_ENTRIES: usize = 10000;
23const DEFAULT_MAX_TOTAL_DECOMPRESSED_SIZE: u64 = 1_073_741_824;
24const DEFAULT_MAX_PER_ENTRY_SIZE: u64 = 512 * 1024 * 1024;
25const DEFAULT_MAX_COMPRESSED_SIZE: u64 = 1_073_741_824;
26const DEFAULT_CHANNEL_CAPACITY: usize = 2;
27
28pub const CAMEL_ZIP_ENTRY_NAME: &str = "CamelZipEntryName";
29pub const CAMEL_ZIP_ENTRY_PATH: &str = "CamelZipEntryPath";
30pub const CAMEL_ZIP_ENTRY_INDEX: &str = "CamelZipEntryIndex";
31pub const CAMEL_ZIP_ENTRY_SIZE: &str = "CamelZipEntrySize";
32pub const CAMEL_ZIP_ENTRY_COMPRESSED_SIZE: &str = "CamelZipEntryCompressedSize";
33pub const CAMEL_ZIP_ENTRY_CRC32: &str = "CamelZipEntryCrc32";
34pub const CAMEL_ZIP_ENTRY_IS_DIRECTORY: &str = "CamelZipEntryIsDirectory";
35pub const CAMEL_ZIP_ENTRY_COMPRESSION: &str = "CamelZipEntryCompression";
36
37#[derive(Debug, Clone)]
38pub struct ZipSplitConfig {
39    pub max_entries: usize,
40    pub max_total_decompressed_size: u64,
41    pub max_per_entry_size: u64,
42    pub max_compressed_size: u64,
43    pub max_path_length: usize,
44    pub allow_empty_directories: bool,
45    pub duplicate_names_policy: DuplicatePolicy,
46    pub channel_capacity: usize,
47}
48
49impl Default for ZipSplitConfig {
50    fn default() -> Self {
51        Self {
52            max_entries: DEFAULT_MAX_ENTRIES,
53            max_total_decompressed_size: DEFAULT_MAX_TOTAL_DECOMPRESSED_SIZE,
54            max_per_entry_size: DEFAULT_MAX_PER_ENTRY_SIZE,
55            max_compressed_size: DEFAULT_MAX_COMPRESSED_SIZE,
56            max_path_length: DEFAULT_MAX_PATH_LENGTH,
57            allow_empty_directories: false,
58            duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
59            channel_capacity: DEFAULT_CHANNEL_CAPACITY,
60        }
61    }
62}
63
64struct ZipEntryData {
65    index: usize,
66    path: String,
67    size: u64,
68    compressed_size: u64,
69    crc32: Option<u32>,
70    is_dir: bool,
71    compression: String,
72    data: Vec<u8>,
73}
74
75/// Split a ZIP archive's bytes into a stream of Exchanges, one per entry.
76///
77/// Takes owned `Bytes` (for `'static` lifetime), a parent `Exchange` whose headers
78/// and properties are cloned into each entry's exchange, and a `ZipSplitConfig`
79/// controlling limits and policy.
80///
81/// This is the core extraction — callers such as `zip_splitter()` or `camel-core`
82/// component code can invoke it directly with already-acquired bytes.
83pub fn split_zip_bytes(
84    parent: Exchange,
85    bytes: Bytes,
86    config: ZipSplitConfig,
87) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>> {
88    Box::pin(async_stream::stream! {
89        if config.channel_capacity == 0 {
90            yield Err(CamelError::Config(
91                "ZipSplitConfig.channel_capacity must be > 0".into(),
92            ));
93            return;
94        }
95
96        if bytes.len() as u64 > config.max_compressed_size {
97            yield Err(CamelError::TypeConversionFailed(format!(
98                "ZIP compressed size {} exceeds max {}",
99                bytes.len(),
100                config.max_compressed_size
101            )));
102            return;
103        }
104
105        let (tx, mut rx) = mpsc::channel::<Result<ZipEntryData, CamelError>>(config.channel_capacity);
106
107        let total_decompressed = Arc::new(AtomicU64::new(0));
108        let entry_count = Arc::new(AtomicUsize::new(0));
109        // Entry name -> occurrence count; drives both duplicate policies and
110        // mirrors the TAR splitter's bookkeeping exactly.
111        let seen_names: Arc<std::sync::Mutex<HashMap<String, usize>>> =
112            Arc::new(std::sync::Mutex::new(HashMap::new()));
113        // Names already emitted (original or indexed). The occurrence
114        // counter alone cannot guarantee uniqueness — a literal entry can
115        // occupy an indexed name first — so this set is the collision
116        // authority, mirroring the TAR splitter.
117        let emitted_names: Arc<std::sync::Mutex<std::collections::HashSet<String>>> =
118            Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
119
120        let max_entries = config.max_entries;
121        let max_per_entry = config.max_per_entry_size;
122        let max_total = config.max_total_decompressed_size;
123        let max_path_len = config.max_path_length;
124        let allow_dirs = config.allow_empty_directories;
125        let dup_policy = config.duplicate_names_policy;
126
127        tokio::task::spawn_blocking(move || {
128            let reader = std::io::Cursor::new(bytes);
129            let mut archive = match zip::ZipArchive::new(reader) {
130                Ok(a) => a,
131                Err(e) => {
132                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
133                        format!("Invalid ZIP archive: {e}"),
134                    )));
135                    return;
136                }
137            };
138
139            for i in 0..archive.len() {
140                let mut entry = match archive.by_index(i) {
141                    Ok(e) => e,
142                    Err(e) => {
143                        let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
144                            format!("Failed to read ZIP entry {i}: {e}"),
145                        )));
146                        return;
147                    }
148                };
149
150                let raw_name = entry.name().to_string();
151                let is_dir = entry.is_dir();
152
153                let mut validated = match validate_entry_path(&raw_name, max_path_len, "ZIP") {
154                    Ok(p) => p,
155                    Err(e) => {
156                        let _ = tx.blocking_send(Err(e));
157                        return;
158                    }
159                };
160
161                if is_dir {
162                    if allow_dirs {
163                        let count = entry_count.fetch_add(1, Ordering::SeqCst);
164                        if count >= max_entries {
165                            let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
166                                format!("ZIP exceeds max entries: {max_entries}"),
167                            )));
168                            return;
169                        }
170                        if tx.blocking_send(Ok(ZipEntryData {
171                            index: count,
172                            path: validated,
173                            size: 0,
174                            compressed_size: entry.compressed_size(),
175                            crc32: Some(entry.crc32()),
176                            is_dir: true,
177                            compression: format!("{:?}", entry.compression()),
178                            data: Vec::new(),
179                        }))
180                        .is_err()
181                        {
182                            return;
183                        }
184                    }
185                    continue;
186                }
187
188                let compressed_size = entry.compressed_size();
189                let crc32 = entry.crc32();
190
191                let mut data = Vec::new();
192                let mut limited =
193                    std::io::Read::take(&mut entry, max_per_entry.saturating_add(1));
194                if let Err(e) = limited.read_to_end(&mut data) {
195                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
196                        format!("Failed to decompress ZIP entry '{raw_name}': {e}"),
197                    )));
198                    return;
199                }
200
201                if data.len() as u64 > max_per_entry {
202                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
203                        format!(
204                            "ZIP entry '{raw_name}' size {} exceeds max {}",
205                            data.len(),
206                            max_per_entry
207                        ),
208                    )));
209                    return;
210                }
211
212                let entry_size = data.len() as u64;
213                let prev_total = total_decompressed.load(Ordering::SeqCst);
214                let new_total = prev_total.saturating_add(entry_size);
215                if new_total > max_total {
216                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
217                        format!("ZIP total decompressed size exceeds max {max_total}"),
218                    )));
219                    return;
220                }
221                total_decompressed.store(new_total, Ordering::SeqCst);
222
223                let count = entry_count.fetch_add(1, Ordering::SeqCst);
224                if count >= max_entries {
225                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
226                        format!("ZIP exceeds max entries: {max_entries}"),
227                    )));
228                    return;
229                }
230
231                match &dup_policy {
232                    DuplicatePolicy::Reject => {
233                        let mut seen = seen_names.lock().unwrap_or_else(|e| e.into_inner());
234                        if seen.contains_key(&validated) {
235                            let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
236                                format!("Duplicate ZIP entry name: {validated}"),
237                            )));
238                            return;
239                        }
240                        seen.insert(validated.clone(), 0);
241                        emitted_names
242                            .lock()
243                            .unwrap_or_else(|e| e.into_inner())
244                            .insert(validated.clone());
245                    }
246                    DuplicatePolicy::AllowWithIndex => {
247                        let mut seen = seen_names.lock().unwrap_or_else(|e| e.into_inner());
248                        let mut emitted_set =
249                            emitted_names.lock().unwrap_or_else(|e| e.into_inner());
250                        let occurrences = seen.entry(validated.clone()).or_insert(0);
251                        let start = if *occurrences > 0 {
252                            *occurrences
253                        } else if emitted_set.contains(&validated) {
254                            1
255                        } else {
256                            0
257                        };
258                        if start > 0 {
259                            // Identical to the TAR splitter: later duplicates
260                            // and literal collisions with already-emitted
261                            // indexed names get a deterministic collision-free
262                            // index inserted before the extension, and the
263                            // suffix grows the name, so the path-length cap
264                            // stays authoritative for indexed names too.
265                            let (candidate, used) =
266                                next_free_indexed_name(&validated, start, &emitted_set);
267                            validated =
268                                match validate_entry_path(&candidate, max_path_len, "ZIP") {
269                                    Ok(p) => p,
270                                    Err(e) => {
271                                        let _ = tx.blocking_send(Err(e));
272                                        return;
273                                    }
274                                };
275                            *occurrences = (*occurrences).max(used + 1);
276                        }
277                        emitted_set.insert(validated.clone());
278                    }
279                }
280
281                if tx
282                    .blocking_send(Ok(ZipEntryData {
283                        index: count,
284                        path: validated,
285                        size: data.len() as u64,
286                        compressed_size,
287                        crc32: Some(crc32),
288                        is_dir: false,
289                        compression: format!("{:?}", entry.compression()),
290                        data,
291                    }))
292                    .is_err()
293                {
294                    return;
295                }
296            }
297        });
298
299        while let Some(result) = rx.recv().await {
300            match result {
301                Ok(entry) => {
302                    let ZipEntryData {
303                        index,
304                        path,
305                        size,
306                        compressed_size,
307                        crc32,
308                        is_dir,
309                        compression,
310                        data,
311                    } = entry;
312                    let body = if is_dir {
313                        Body::Empty
314                    } else {
315                        Body::Bytes(Bytes::from(data))
316                    };
317                    let msg = Message {
318                        headers: parent.input.headers.clone(),
319                        body,
320                    };
321                    let mut ex = Exchange::new(msg);
322                    // Strip parent-level content headers that are stale for individual ZIP entries
323                    ex.input.headers.remove("Content-Length");
324                    ex.input.headers.remove("Content-Type");
325                    ex.properties = parent.properties.clone();
326                    ex.pattern = parent.pattern;
327                    ex.otel_context = parent.otel_context.clone();
328
329                    let entry_name = Path::new(&path)
330                        .file_name()
331                        .map(|n| n.to_string_lossy().to_string())
332                        .unwrap_or_default();
333
334                    ex.input.headers.insert(
335                        CAMEL_ZIP_ENTRY_NAME.to_string(),
336                        Value::String(entry_name),
337                    );
338                    ex.input.headers.insert(
339                        CAMEL_ZIP_ENTRY_PATH.to_string(),
340                        Value::String(path),
341                    );
342                    ex.input.headers.insert(
343                        CAMEL_ZIP_ENTRY_INDEX.to_string(),
344                        Value::from(index as u64),
345                    );
346                    ex.input.headers
347                        .insert(CAMEL_ZIP_ENTRY_SIZE.to_string(), Value::from(size));
348                    ex.input.headers.insert(
349                        CAMEL_ZIP_ENTRY_COMPRESSED_SIZE.to_string(),
350                        Value::from(compressed_size),
351                    );
352                    if let Some(crc) = crc32 {
353                        ex.input
354                            .headers
355                            .insert(CAMEL_ZIP_ENTRY_CRC32.to_string(), Value::from(crc));
356                    }
357                    ex.input.headers.insert(
358                        CAMEL_ZIP_ENTRY_IS_DIRECTORY.to_string(),
359                        Value::Bool(is_dir),
360                    );
361                    ex.input.headers.insert(
362                        CAMEL_ZIP_ENTRY_COMPRESSION.to_string(),
363                        Value::String(compression),
364                    );
365
366                    yield Ok(ex);
367                }
368                Err(e) => {
369                    yield Err(e);
370                }
371            }
372        }
373    })
374}
375
376pub fn zip_splitter(config: ZipSplitConfig) -> StreamingSplitExpression {
377    Arc::new(move |exchange: Exchange| {
378        let config = config.clone();
379        match exchange.input.body.clone() {
380            Body::Bytes(b) => split_zip_bytes(exchange, b, config),
381            Body::Text(s) => split_zip_bytes(exchange, Bytes::from(s.as_bytes().to_vec()), config),
382            _ => Box::pin(async_stream::stream! {
383                yield Err(CamelError::TypeConversionFailed(
384                    "ZipSplitter requires Body::Bytes or Body::Text".to_string(),
385                ));
386            }),
387        }
388    })
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::archive_splitter::test_util::make_zip_raw;
395    use futures::StreamExt;
396    use std::io::Write;
397
398    fn make_zip_with_files(files: Vec<(&str, &[u8])>) -> Vec<u8> {
399        let mut buf = Vec::new();
400        {
401            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
402            let options = zip::write::SimpleFileOptions::default()
403                .compression_method(zip::CompressionMethod::Deflated);
404            for (name, content) in &files {
405                writer.start_file(*name, options).unwrap();
406                writer.write_all(content).unwrap();
407            }
408            writer.finish().unwrap();
409        }
410        buf
411    }
412
413    fn make_zip_with_dirs(entries: Vec<(&str, bool)>) -> Vec<u8> {
414        let mut buf = Vec::new();
415        {
416            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
417            let options = zip::write::SimpleFileOptions::default();
418            for (name, is_dir) in &entries {
419                if *is_dir {
420                    writer.add_directory(*name, options).unwrap();
421                } else {
422                    writer.start_file(name, options).unwrap();
423                    writer.write_all(b"content").unwrap();
424                }
425            }
426            writer.finish().unwrap();
427        }
428        buf
429    }
430
431    async fn collect_entries(
432        config: ZipSplitConfig,
433        zip_data: Vec<u8>,
434    ) -> Vec<Result<Exchange, CamelError>> {
435        let expr = zip_splitter(config);
436        let exchange = Exchange::new(Message {
437            headers: Default::default(),
438            body: Body::Bytes(Bytes::from(zip_data)),
439        });
440        let stream = expr(exchange);
441        stream.collect().await
442    }
443
444    #[tokio::test]
445    async fn test_zip_split_single_file() {
446        let zip_data = make_zip_with_files(vec![("hello.txt", b"hello world")]);
447        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
448        assert_eq!(results.len(), 1);
449        let ex = results[0].as_ref().unwrap();
450        match &ex.input.body {
451            Body::Bytes(b) => assert_eq!(b.as_ref(), b"hello world"),
452            _ => panic!("expected Body::Bytes"),
453        }
454        assert_eq!(
455            ex.input.headers.get(CAMEL_ZIP_ENTRY_NAME),
456            Some(&Value::String("hello.txt".to_string()))
457        );
458    }
459
460    #[tokio::test]
461    async fn test_zip_split_multiple_files() {
462        let zip_data = make_zip_with_files(vec![("a.txt", b"aaa"), ("b.txt", b"bbb")]);
463        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
464        assert_eq!(results.len(), 2);
465    }
466
467    #[tokio::test]
468    async fn test_zip_split_with_directories() {
469        let zip_data = make_zip_with_dirs(vec![("subdir/", true), ("subdir/file.txt", false)]);
470        let config = ZipSplitConfig {
471            allow_empty_directories: true,
472            ..Default::default()
473        };
474        let results = collect_entries(config, zip_data).await;
475        assert_eq!(results.len(), 2);
476        let dir_ex = results[0].as_ref().unwrap();
477        assert!(dir_ex.input.body.is_empty());
478        assert_eq!(
479            dir_ex.input.headers.get(CAMEL_ZIP_ENTRY_IS_DIRECTORY),
480            Some(&Value::Bool(true))
481        );
482    }
483
484    #[tokio::test]
485    async fn test_zip_split_preserves_paths() {
486        let zip_data = make_zip_with_files(vec![("deep/nested/path/file.txt", b"deep")]);
487        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
488        assert_eq!(results.len(), 1);
489        let ex = results[0].as_ref().unwrap();
490        assert_eq!(
491            ex.input.headers.get(CAMEL_ZIP_ENTRY_PATH),
492            Some(&Value::String("deep/nested/path/file.txt".to_string()))
493        );
494    }
495
496    #[tokio::test]
497    async fn test_zip_split_max_entries_exceeded() {
498        let files: Vec<(String, Vec<u8>)> = (0..5)
499            .map(|i| (format!("f{i}.txt"), b"x".to_vec()))
500            .collect();
501        let zip_data = {
502            let mut buf = Vec::new();
503            {
504                let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
505                let options = zip::write::SimpleFileOptions::default();
506                for (name, content) in &files {
507                    writer.start_file(name, options).unwrap();
508                    writer.write_all(content).unwrap();
509                }
510                writer.finish().unwrap();
511            }
512            buf
513        };
514        let config = ZipSplitConfig {
515            max_entries: 3,
516            ..Default::default()
517        };
518        let results = collect_entries(config, zip_data).await;
519        let has_error = results.iter().any(|r| r.is_err());
520        assert!(has_error);
521    }
522
523    #[tokio::test]
524    async fn test_zip_split_path_traversal_rejected() {
525        let mut buf = Vec::new();
526        {
527            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
528            let options = zip::write::SimpleFileOptions::default();
529            writer.start_file("../etc/passwd", options).unwrap();
530            writer.write_all(b"oops").unwrap();
531            writer.finish().unwrap();
532        }
533        let results = collect_entries(ZipSplitConfig::default(), buf).await;
534        let has_error = results.iter().any(|r| r.is_err());
535        assert!(has_error);
536    }
537
538    #[tokio::test]
539    async fn test_zip_split_headers_set() {
540        let zip_data = make_zip_with_files(vec![("test.txt", b"content")]);
541        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
542        let ex = results[0].as_ref().unwrap();
543        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_NAME));
544        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_PATH));
545        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_INDEX));
546        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_SIZE));
547        assert!(
548            ex.input
549                .headers
550                .contains_key(CAMEL_ZIP_ENTRY_COMPRESSED_SIZE)
551        );
552        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_IS_DIRECTORY));
553        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_COMPRESSION));
554    }
555
556    #[tokio::test]
557    async fn test_zip_split_empty_zip() {
558        let mut buf = Vec::new();
559        {
560            let writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
561            writer.finish().unwrap();
562        }
563        let results = collect_entries(ZipSplitConfig::default(), buf).await;
564        assert!(results.is_empty());
565    }
566
567    /// Under `Reject`, unique names always pass. True duplicate names never
568    /// reach the splitter — the `zip` reader indexes the central directory by
569    /// name and collapses them — so the reject branch is defense-in-depth
570    /// shared with the TAR splitter (see
571    /// `archive_splitter::indexed_duplicate_name` and the cross-format parity
572    /// test in `tar_splitter`).
573    #[tokio::test]
574    async fn test_zip_split_duplicate_names_reject() {
575        let files: Vec<(&str, &[u8])> = vec![("a.txt", b"first"), ("b.txt", b"second")];
576        let zip_data = make_zip_with_files(files);
577        let config = ZipSplitConfig {
578            duplicate_names_policy: DuplicatePolicy::Reject,
579            ..Default::default()
580        };
581        let results = collect_entries(config, zip_data).await;
582        assert_eq!(results.len(), 2);
583        assert!(results.iter().all(|r| r.is_ok()));
584    }
585    /// Boundary guard: a hand-built ZIP carrying true duplicate names has
586    /// them collapsed by the `zip` reader (first position, last entry's data
587    /// wins) before the splitter sees it, so `AllowWithIndex` observes only
588    /// unique names and never mangles. If a reader upgrade starts surfacing
589    /// duplicates, this test fails and the shared index-mangling branch
590    /// becomes live end-to-end.
591    #[tokio::test]
592    async fn test_zip_split_raw_duplicate_entries_collapse_at_reader() {
593        let zip_data = make_zip_raw(&[
594            ("dup.txt", b"first"),
595            ("other.txt", b"mid"),
596            ("dup.txt", b"second"),
597        ]);
598        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
599        assert_eq!(
600            results.len(),
601            2,
602            "duplicate names must collapse at the reader: {results:?}"
603        );
604        let first = results[0].as_ref().unwrap();
605        assert_eq!(
606            first.input.headers.get(CAMEL_ZIP_ENTRY_PATH),
607            Some(&Value::String("dup.txt".to_string())),
608            "the first position wins with the surviving name unmangled"
609        );
610        match &first.input.body {
611            Body::Bytes(b) => assert_eq!(b.as_ref(), b"second", "last entry's data wins"),
612            other => panic!("expected Body::Bytes, got {other:?}"),
613        }
614        assert_eq!(
615            results[1]
616                .as_ref()
617                .unwrap()
618                .input
619                .headers
620                .get(CAMEL_ZIP_ENTRY_PATH),
621            Some(&Value::String("other.txt".to_string()))
622        );
623    }
624
625    #[tokio::test]
626    async fn test_zip_split_max_per_entry_size_exceeded() {
627        let zip_data = make_zip_with_files(vec![("big.txt", b"x".repeat(200).as_slice())]);
628        let config = ZipSplitConfig {
629            max_per_entry_size: 100,
630            ..Default::default()
631        };
632        let results = collect_entries(config, zip_data).await;
633        let has_error = results.iter().any(|r| r.is_err());
634        assert!(has_error);
635    }
636
637    #[tokio::test]
638    async fn test_zip_split_max_total_decompressed_size_exceeded() {
639        let zip_data =
640            make_zip_with_files(vec![("a.txt", b"aaaaaaaaaa"), ("b.txt", b"bbbbbbbbbb")]);
641        let config = ZipSplitConfig {
642            max_total_decompressed_size: 15,
643            ..Default::default()
644        };
645        let results = collect_entries(config, zip_data).await;
646        let has_error = results.iter().any(|r| r.is_err());
647        assert!(has_error);
648    }
649
650    #[tokio::test]
651    async fn test_zip_split_corrupt_zip() {
652        let results = collect_entries(ZipSplitConfig::default(), b"not a zip file".to_vec()).await;
653        let has_error = results.iter().any(|r| r.is_err());
654        assert!(has_error);
655    }
656
657    #[tokio::test]
658    async fn test_zip_split_backslash_rejected() {
659        let mut buf = Vec::new();
660        {
661            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
662            let options = zip::write::SimpleFileOptions::default();
663            writer.start_file("sub\\file.txt", options).unwrap();
664            writer.write_all(b"oops").unwrap();
665            writer.finish().unwrap();
666        }
667        let results = collect_entries(ZipSplitConfig::default(), buf).await;
668        let has_error = results.iter().any(|r| r.is_err());
669        assert!(has_error);
670    }
671
672    /// Regression guard for the shared-validator extraction: safe names are
673    /// accepted and unsafe names are rejected with the exact pre-refactor
674    /// ZIP error text.
675    #[tokio::test]
676    async fn zip_path_validation_behavior_is_unchanged() {
677        // Safe relative name is accepted with its path preserved.
678        let safe = collect_entries(
679            ZipSplitConfig::default(),
680            make_zip_with_files(vec![("docs/readme.txt", b"ok")]),
681        )
682        .await;
683        assert_eq!(safe.len(), 1);
684        let ex = safe[0].as_ref().unwrap();
685        assert_eq!(
686            ex.input.headers.get(CAMEL_ZIP_ENTRY_PATH),
687            Some(&Value::String("docs/readme.txt".to_string()))
688        );
689
690        // Absolute and traversal names are rejected with identical error text.
691        let cases = [
692            ("/etc/passwd", "ZIP entry path is absolute: /etc/passwd"),
693            (
694                "../etc/passwd",
695                "ZIP entry path contains '..' traversal: ../etc/passwd",
696            ),
697        ];
698        for (name, expected) in cases {
699            let results = collect_entries(
700                ZipSplitConfig::default(),
701                make_zip_with_files(vec![(name, b"oops")]),
702            )
703            .await;
704            assert_eq!(
705                results.len(),
706                1,
707                "expected exactly the validation error for {name}"
708            );
709            let err = results[0]
710                .as_ref()
711                .expect_err(&format!("'{name}' must be rejected"))
712                .to_string();
713            assert!(
714                err.contains(expected),
715                "error text mismatch for {name}: {err}"
716            );
717        }
718    }
719}