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 = match entry.name() {
151                    Ok(n) => n.into_owned(),
152                    Err(e) => {
153                        let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
154                            format!("Failed to read ZIP entry {i} name: {e}"),
155                        )));
156                        return;
157                    }
158                };
159                let is_dir = entry.is_dir();
160
161                let mut validated = match validate_entry_path(&raw_name, max_path_len, "ZIP") {
162                    Ok(p) => p,
163                    Err(e) => {
164                        let _ = tx.blocking_send(Err(e));
165                        return;
166                    }
167                };
168
169                if is_dir {
170                    if allow_dirs {
171                        let count = entry_count.fetch_add(1, Ordering::SeqCst);
172                        if count >= max_entries {
173                            let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
174                                format!("ZIP exceeds max entries: {max_entries}"),
175                            )));
176                            return;
177                        }
178                        if tx.blocking_send(Ok(ZipEntryData {
179                            index: count,
180                            path: validated,
181                            size: 0,
182                            compressed_size: entry.compressed_size(),
183                            crc32: Some(entry.crc32()),
184                            is_dir: true,
185                            compression: format!("{:?}", entry.compression()),
186                            data: Vec::new(),
187                        }))
188                        .is_err()
189                        {
190                            return;
191                        }
192                    }
193                    continue;
194                }
195
196                let compressed_size = entry.compressed_size();
197                let crc32 = entry.crc32();
198
199                let mut data = Vec::new();
200                let mut limited =
201                    std::io::Read::take(&mut entry, max_per_entry.saturating_add(1));
202                if let Err(e) = limited.read_to_end(&mut data) {
203                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
204                        format!("Failed to decompress ZIP entry '{raw_name}': {e}"),
205                    )));
206                    return;
207                }
208
209                if data.len() as u64 > max_per_entry {
210                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
211                        format!(
212                            "ZIP entry '{raw_name}' size {} exceeds max {}",
213                            data.len(),
214                            max_per_entry
215                        ),
216                    )));
217                    return;
218                }
219
220                let entry_size = data.len() as u64;
221                let prev_total = total_decompressed.load(Ordering::SeqCst);
222                let new_total = prev_total.saturating_add(entry_size);
223                if new_total > max_total {
224                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
225                        format!("ZIP total decompressed size exceeds max {max_total}"),
226                    )));
227                    return;
228                }
229                total_decompressed.store(new_total, Ordering::SeqCst);
230
231                let count = entry_count.fetch_add(1, Ordering::SeqCst);
232                if count >= max_entries {
233                    let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
234                        format!("ZIP exceeds max entries: {max_entries}"),
235                    )));
236                    return;
237                }
238
239                match &dup_policy {
240                    DuplicatePolicy::Reject => {
241                        let mut seen = seen_names.lock().unwrap_or_else(|e| e.into_inner());
242                        if seen.contains_key(&validated) {
243                            let _ = tx.blocking_send(Err(CamelError::TypeConversionFailed(
244                                format!("Duplicate ZIP entry name: {validated}"),
245                            )));
246                            return;
247                        }
248                        seen.insert(validated.clone(), 0);
249                        emitted_names
250                            .lock()
251                            .unwrap_or_else(|e| e.into_inner())
252                            .insert(validated.clone());
253                    }
254                    DuplicatePolicy::AllowWithIndex => {
255                        let mut seen = seen_names.lock().unwrap_or_else(|e| e.into_inner());
256                        let mut emitted_set =
257                            emitted_names.lock().unwrap_or_else(|e| e.into_inner());
258                        let occurrences = seen.entry(validated.clone()).or_insert(0);
259                        let start = if *occurrences > 0 {
260                            *occurrences
261                        } else if emitted_set.contains(&validated) {
262                            1
263                        } else {
264                            0
265                        };
266                        if start > 0 {
267                            // Identical to the TAR splitter: later duplicates
268                            // and literal collisions with already-emitted
269                            // indexed names get a deterministic collision-free
270                            // index inserted before the extension, and the
271                            // suffix grows the name, so the path-length cap
272                            // stays authoritative for indexed names too.
273                            let (candidate, used) =
274                                next_free_indexed_name(&validated, start, &emitted_set);
275                            validated =
276                                match validate_entry_path(&candidate, max_path_len, "ZIP") {
277                                    Ok(p) => p,
278                                    Err(e) => {
279                                        let _ = tx.blocking_send(Err(e));
280                                        return;
281                                    }
282                                };
283                            *occurrences = (*occurrences).max(used + 1);
284                        }
285                        emitted_set.insert(validated.clone());
286                    }
287                }
288
289                if tx
290                    .blocking_send(Ok(ZipEntryData {
291                        index: count,
292                        path: validated,
293                        size: data.len() as u64,
294                        compressed_size,
295                        crc32: Some(crc32),
296                        is_dir: false,
297                        compression: format!("{:?}", entry.compression()),
298                        data,
299                    }))
300                    .is_err()
301                {
302                    return;
303                }
304            }
305        });
306
307        while let Some(result) = rx.recv().await {
308            match result {
309                Ok(entry) => {
310                    let ZipEntryData {
311                        index,
312                        path,
313                        size,
314                        compressed_size,
315                        crc32,
316                        is_dir,
317                        compression,
318                        data,
319                    } = entry;
320                    let body = if is_dir {
321                        Body::Empty
322                    } else {
323                        Body::Bytes(Bytes::from(data))
324                    };
325                    let msg = Message {
326                        headers: parent.input.headers.clone(),
327                        body,
328                    };
329                    let mut ex = Exchange::new(msg);
330                    // Strip parent-level content headers that are stale for individual ZIP entries
331                    ex.input.headers.remove("Content-Length");
332                    ex.input.headers.remove("Content-Type");
333                    ex.properties = parent.properties.clone();
334                    ex.pattern = parent.pattern;
335                    ex.otel_context = parent.otel_context.clone();
336
337                    let entry_name = Path::new(&path)
338                        .file_name()
339                        .map(|n| n.to_string_lossy().to_string())
340                        .unwrap_or_default();
341
342                    ex.input.headers.insert(
343                        CAMEL_ZIP_ENTRY_NAME.to_string(),
344                        Value::String(entry_name),
345                    );
346                    ex.input.headers.insert(
347                        CAMEL_ZIP_ENTRY_PATH.to_string(),
348                        Value::String(path),
349                    );
350                    ex.input.headers.insert(
351                        CAMEL_ZIP_ENTRY_INDEX.to_string(),
352                        Value::from(index as u64),
353                    );
354                    ex.input.headers
355                        .insert(CAMEL_ZIP_ENTRY_SIZE.to_string(), Value::from(size));
356                    ex.input.headers.insert(
357                        CAMEL_ZIP_ENTRY_COMPRESSED_SIZE.to_string(),
358                        Value::from(compressed_size),
359                    );
360                    if let Some(crc) = crc32 {
361                        ex.input
362                            .headers
363                            .insert(CAMEL_ZIP_ENTRY_CRC32.to_string(), Value::from(crc));
364                    }
365                    ex.input.headers.insert(
366                        CAMEL_ZIP_ENTRY_IS_DIRECTORY.to_string(),
367                        Value::Bool(is_dir),
368                    );
369                    ex.input.headers.insert(
370                        CAMEL_ZIP_ENTRY_COMPRESSION.to_string(),
371                        Value::String(compression),
372                    );
373
374                    yield Ok(ex);
375                }
376                Err(e) => {
377                    yield Err(e);
378                }
379            }
380        }
381    })
382}
383
384pub fn zip_splitter(config: ZipSplitConfig) -> StreamingSplitExpression {
385    Arc::new(move |exchange: Exchange| {
386        let config = config.clone();
387        match exchange.input.body.clone() {
388            Body::Bytes(b) => split_zip_bytes(exchange, b, config),
389            Body::Text(s) => split_zip_bytes(exchange, Bytes::from(s.as_bytes().to_vec()), config),
390            _ => Box::pin(async_stream::stream! {
391                yield Err(CamelError::TypeConversionFailed(
392                    "ZipSplitter requires Body::Bytes or Body::Text".to_string(),
393                ));
394            }),
395        }
396    })
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::archive_splitter::test_util::make_zip_raw;
403    use futures::StreamExt;
404    use std::io::Write;
405
406    fn make_zip_with_files(files: Vec<(&str, &[u8])>) -> Vec<u8> {
407        let mut buf = Vec::new();
408        {
409            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
410            let options = zip::write::SimpleFileOptions::default()
411                .compression_method(zip::CompressionMethod::Deflated);
412            for (name, content) in &files {
413                writer.start_file(*name, options).unwrap();
414                writer.write_all(content).unwrap();
415            }
416            writer.finish().unwrap();
417        }
418        buf
419    }
420
421    fn make_zip_with_dirs(entries: Vec<(&str, bool)>) -> Vec<u8> {
422        let mut buf = Vec::new();
423        {
424            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
425            let options = zip::write::SimpleFileOptions::default();
426            for (name, is_dir) in &entries {
427                if *is_dir {
428                    writer.add_directory(*name, options).unwrap();
429                } else {
430                    writer.start_file(name, options).unwrap();
431                    writer.write_all(b"content").unwrap();
432                }
433            }
434            writer.finish().unwrap();
435        }
436        buf
437    }
438
439    async fn collect_entries(
440        config: ZipSplitConfig,
441        zip_data: Vec<u8>,
442    ) -> Vec<Result<Exchange, CamelError>> {
443        let expr = zip_splitter(config);
444        let exchange = Exchange::new(Message {
445            headers: Default::default(),
446            body: Body::Bytes(Bytes::from(zip_data)),
447        });
448        let stream = expr(exchange);
449        stream.collect().await
450    }
451
452    #[tokio::test]
453    async fn test_zip_split_single_file() {
454        let zip_data = make_zip_with_files(vec![("hello.txt", b"hello world")]);
455        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
456        assert_eq!(results.len(), 1);
457        let ex = results[0].as_ref().unwrap();
458        match &ex.input.body {
459            Body::Bytes(b) => assert_eq!(b.as_ref(), b"hello world"),
460            _ => panic!("expected Body::Bytes"),
461        }
462        assert_eq!(
463            ex.input.headers.get(CAMEL_ZIP_ENTRY_NAME),
464            Some(&Value::String("hello.txt".to_string()))
465        );
466    }
467
468    #[tokio::test]
469    async fn test_zip_split_multiple_files() {
470        let zip_data = make_zip_with_files(vec![("a.txt", b"aaa"), ("b.txt", b"bbb")]);
471        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
472        assert_eq!(results.len(), 2);
473    }
474
475    #[tokio::test]
476    async fn test_zip_split_with_directories() {
477        let zip_data = make_zip_with_dirs(vec![("subdir/", true), ("subdir/file.txt", false)]);
478        let config = ZipSplitConfig {
479            allow_empty_directories: true,
480            ..Default::default()
481        };
482        let results = collect_entries(config, zip_data).await;
483        assert_eq!(results.len(), 2);
484        let dir_ex = results[0].as_ref().unwrap();
485        assert!(dir_ex.input.body.is_empty());
486        assert_eq!(
487            dir_ex.input.headers.get(CAMEL_ZIP_ENTRY_IS_DIRECTORY),
488            Some(&Value::Bool(true))
489        );
490    }
491
492    #[tokio::test]
493    async fn test_zip_split_preserves_paths() {
494        let zip_data = make_zip_with_files(vec![("deep/nested/path/file.txt", b"deep")]);
495        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
496        assert_eq!(results.len(), 1);
497        let ex = results[0].as_ref().unwrap();
498        assert_eq!(
499            ex.input.headers.get(CAMEL_ZIP_ENTRY_PATH),
500            Some(&Value::String("deep/nested/path/file.txt".to_string()))
501        );
502    }
503
504    #[tokio::test]
505    async fn test_zip_split_max_entries_exceeded() {
506        let files: Vec<(String, Vec<u8>)> = (0..5)
507            .map(|i| (format!("f{i}.txt"), b"x".to_vec()))
508            .collect();
509        let zip_data = {
510            let mut buf = Vec::new();
511            {
512                let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
513                let options = zip::write::SimpleFileOptions::default();
514                for (name, content) in &files {
515                    writer.start_file(name, options).unwrap();
516                    writer.write_all(content).unwrap();
517                }
518                writer.finish().unwrap();
519            }
520            buf
521        };
522        let config = ZipSplitConfig {
523            max_entries: 3,
524            ..Default::default()
525        };
526        let results = collect_entries(config, zip_data).await;
527        let has_error = results.iter().any(|r| r.is_err());
528        assert!(has_error);
529    }
530
531    #[tokio::test]
532    async fn test_zip_split_path_traversal_rejected() {
533        let mut buf = Vec::new();
534        {
535            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
536            let options = zip::write::SimpleFileOptions::default();
537            writer.start_file("../etc/passwd", options).unwrap();
538            writer.write_all(b"oops").unwrap();
539            writer.finish().unwrap();
540        }
541        let results = collect_entries(ZipSplitConfig::default(), buf).await;
542        let has_error = results.iter().any(|r| r.is_err());
543        assert!(has_error);
544    }
545
546    #[tokio::test]
547    async fn test_zip_split_headers_set() {
548        let zip_data = make_zip_with_files(vec![("test.txt", b"content")]);
549        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
550        let ex = results[0].as_ref().unwrap();
551        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_NAME));
552        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_PATH));
553        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_INDEX));
554        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_SIZE));
555        assert!(
556            ex.input
557                .headers
558                .contains_key(CAMEL_ZIP_ENTRY_COMPRESSED_SIZE)
559        );
560        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_IS_DIRECTORY));
561        assert!(ex.input.headers.contains_key(CAMEL_ZIP_ENTRY_COMPRESSION));
562    }
563
564    #[tokio::test]
565    async fn test_zip_split_empty_zip() {
566        let mut buf = Vec::new();
567        {
568            let writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
569            writer.finish().unwrap();
570        }
571        let results = collect_entries(ZipSplitConfig::default(), buf).await;
572        assert!(results.is_empty());
573    }
574
575    /// Under `Reject`, unique names always pass. True duplicate names never
576    /// reach the splitter — the `zip` reader indexes the central directory by
577    /// name and collapses them — so the reject branch is defense-in-depth
578    /// shared with the TAR splitter (see
579    /// `archive_splitter::indexed_duplicate_name` and the cross-format parity
580    /// test in `tar_splitter`).
581    #[tokio::test]
582    async fn test_zip_split_duplicate_names_reject() {
583        let files: Vec<(&str, &[u8])> = vec![("a.txt", b"first"), ("b.txt", b"second")];
584        let zip_data = make_zip_with_files(files);
585        let config = ZipSplitConfig {
586            duplicate_names_policy: DuplicatePolicy::Reject,
587            ..Default::default()
588        };
589        let results = collect_entries(config, zip_data).await;
590        assert_eq!(results.len(), 2);
591        assert!(results.iter().all(|r| r.is_ok()));
592    }
593    /// Boundary guard: a hand-built ZIP carrying true duplicate names has
594    /// them collapsed by the `zip` reader (first position, last entry's data
595    /// wins) before the splitter sees it, so `AllowWithIndex` observes only
596    /// unique names and never mangles. If a reader upgrade starts surfacing
597    /// duplicates, this test fails and the shared index-mangling branch
598    /// becomes live end-to-end.
599    #[tokio::test]
600    async fn test_zip_split_raw_duplicate_entries_collapse_at_reader() {
601        let zip_data = make_zip_raw(&[
602            ("dup.txt", b"first"),
603            ("other.txt", b"mid"),
604            ("dup.txt", b"second"),
605        ]);
606        let results = collect_entries(ZipSplitConfig::default(), zip_data).await;
607        assert_eq!(
608            results.len(),
609            2,
610            "duplicate names must collapse at the reader: {results:?}"
611        );
612        let first = results[0].as_ref().unwrap();
613        assert_eq!(
614            first.input.headers.get(CAMEL_ZIP_ENTRY_PATH),
615            Some(&Value::String("dup.txt".to_string())),
616            "the first position wins with the surviving name unmangled"
617        );
618        match &first.input.body {
619            Body::Bytes(b) => assert_eq!(b.as_ref(), b"second", "last entry's data wins"),
620            other => panic!("expected Body::Bytes, got {other:?}"),
621        }
622        assert_eq!(
623            results[1]
624                .as_ref()
625                .unwrap()
626                .input
627                .headers
628                .get(CAMEL_ZIP_ENTRY_PATH),
629            Some(&Value::String("other.txt".to_string()))
630        );
631    }
632
633    #[tokio::test]
634    async fn test_zip_split_max_per_entry_size_exceeded() {
635        let zip_data = make_zip_with_files(vec![("big.txt", b"x".repeat(200).as_slice())]);
636        let config = ZipSplitConfig {
637            max_per_entry_size: 100,
638            ..Default::default()
639        };
640        let results = collect_entries(config, zip_data).await;
641        let has_error = results.iter().any(|r| r.is_err());
642        assert!(has_error);
643    }
644
645    #[tokio::test]
646    async fn test_zip_split_max_total_decompressed_size_exceeded() {
647        let zip_data =
648            make_zip_with_files(vec![("a.txt", b"aaaaaaaaaa"), ("b.txt", b"bbbbbbbbbb")]);
649        let config = ZipSplitConfig {
650            max_total_decompressed_size: 15,
651            ..Default::default()
652        };
653        let results = collect_entries(config, zip_data).await;
654        let has_error = results.iter().any(|r| r.is_err());
655        assert!(has_error);
656    }
657
658    #[tokio::test]
659    async fn test_zip_split_corrupt_zip() {
660        let results = collect_entries(ZipSplitConfig::default(), b"not a zip file".to_vec()).await;
661        let has_error = results.iter().any(|r| r.is_err());
662        assert!(has_error);
663    }
664
665    #[tokio::test]
666    async fn test_zip_split_backslash_rejected() {
667        let mut buf = Vec::new();
668        {
669            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
670            let options = zip::write::SimpleFileOptions::default();
671            writer.start_file("sub\\file.txt", options).unwrap();
672            writer.write_all(b"oops").unwrap();
673            writer.finish().unwrap();
674        }
675        let results = collect_entries(ZipSplitConfig::default(), buf).await;
676        let has_error = results.iter().any(|r| r.is_err());
677        assert!(has_error);
678    }
679
680    /// Regression guard for the shared-validator extraction: safe names are
681    /// accepted and unsafe names are rejected with the exact pre-refactor
682    /// ZIP error text.
683    #[tokio::test]
684    async fn zip_path_validation_behavior_is_unchanged() {
685        // Safe relative name is accepted with its path preserved.
686        let safe = collect_entries(
687            ZipSplitConfig::default(),
688            make_zip_with_files(vec![("docs/readme.txt", b"ok")]),
689        )
690        .await;
691        assert_eq!(safe.len(), 1);
692        let ex = safe[0].as_ref().unwrap();
693        assert_eq!(
694            ex.input.headers.get(CAMEL_ZIP_ENTRY_PATH),
695            Some(&Value::String("docs/readme.txt".to_string()))
696        );
697
698        // Absolute and traversal names are rejected with identical error text.
699        let cases = [
700            ("/etc/passwd", "ZIP entry path is absolute: /etc/passwd"),
701            (
702                "../etc/passwd",
703                "ZIP entry path contains '..' traversal: ../etc/passwd",
704            ),
705        ];
706        for (name, expected) in cases {
707            let results = collect_entries(
708                ZipSplitConfig::default(),
709                make_zip_with_files(vec![(name, b"oops")]),
710            )
711            .await;
712            assert_eq!(
713                results.len(),
714                1,
715                "expected exactly the validation error for {name}"
716            );
717            let err = results[0]
718                .as_ref()
719                .expect_err(&format!("'{name}' must be rejected"))
720                .to_string();
721            assert!(
722                err.contains(expected),
723                "error text mismatch for {name}: {err}"
724            );
725        }
726    }
727}