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 = 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 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 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 #[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 #[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 #[tokio::test]
676 async fn zip_path_validation_behavior_is_unchanged() {
677 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 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}