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
17pub 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
75pub 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 let seen_names: Arc<std::sync::Mutex<HashMap<String, usize>>> =
112 Arc::new(std::sync::Mutex::new(HashMap::new()));
113 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 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 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 #[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 #[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 #[tokio::test]
684 async fn zip_path_validation_behavior_is_unchanged() {
685 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 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}