1use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 McdPackage,
8 annotations::{load_manifest_annotations, validate_annotation_markers},
9 directives::TableDisplay,
10 document::{DocumentBlock, McdDocument, SourceSpan},
11 errors::{Diagnostic, McdError},
12 images::{load_manifest_images, validate_image_anchors},
13 provenance::load_manifest_provenance,
14 table_view::TableView,
15 tables::{DataTable, load_manifest_tables, row_key},
16};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ValidationResult {
21 pub valid: bool,
23 pub diagnostics: Vec<Diagnostic>,
25}
26
27impl ValidationResult {
28 #[must_use]
30 pub fn valid() -> Self {
31 Self {
32 valid: true,
33 diagnostics: Vec::new(),
34 }
35 }
36}
37
38pub fn validate_package(package: &McdPackage) -> crate::Result<ValidationResult> {
40 let manifest = package.manifest()?;
41 let document = McdDocument::from_package(package, &manifest)?;
42 let tables = load_manifest_tables(package, &manifest)?;
43 validate_foreign_keys(&tables)?;
44 let views = load_and_validate_views(package, &manifest, &tables)?;
45 validate_table_anchors(&document, &tables, &views)?;
46 let images = load_manifest_images(package, &manifest)?;
47 validate_image_anchors(&document, &images)?;
48 let annotations = load_manifest_annotations(package, &manifest, &document)?;
49 validate_annotation_markers(&document, &annotations)?;
50 load_manifest_provenance(package, &manifest)?;
51 Ok(ValidationResult::valid())
52}
53
54fn validate_foreign_keys(tables: &IndexMap<String, DataTable>) -> crate::Result<()> {
55 for table in tables.values() {
56 for foreign_key in &table.schema.foreign_keys {
57 let target = tables.get(&foreign_key.references.table).ok_or_else(|| {
58 McdError::from_diagnostic(
59 Diagnostic::error(
60 "schema.foreign_key.references.table.unresolved",
61 format!(
62 "Foreign key in table '{}' references unknown table '{}'.",
63 table.id, foreign_key.references.table
64 ),
65 )
66 .with_source(table.source.clone()),
67 )
68 })?;
69
70 if foreign_key.references.columns != target.schema.primary_key {
71 return Err(McdError::from_diagnostic(
72 Diagnostic::error(
73 "schema.foreign_key.references.primary_key.mismatch",
74 format!(
75 "Foreign key in table '{}' must reference the primary key of table '{}'.",
76 table.id, target.id
77 ),
78 )
79 .with_source(table.source.clone()),
80 ));
81 }
82
83 for (local_column, target_column) in foreign_key
84 .columns
85 .iter()
86 .zip(foreign_key.references.columns.iter())
87 {
88 let local = table.schema.column(local_column).ok_or_else(|| {
89 McdError::from_diagnostic(
90 Diagnostic::error(
91 "schema.foreign_key.column.unknown",
92 format!(
93 "Foreign key in table '{}' references unknown local column '{}'.",
94 table.id, local_column
95 ),
96 )
97 .with_source(table.source.clone()),
98 )
99 })?;
100 let target = target.schema.column(target_column).ok_or_else(|| {
101 McdError::from_diagnostic(
102 Diagnostic::error(
103 "schema.foreign_key.references.column.unknown",
104 format!(
105 "Foreign key in table '{}' references unknown column '{}' on table '{}'.",
106 table.id, target_column, foreign_key.references.table
107 ),
108 )
109 .with_source(table.source.clone()),
110 )
111 })?;
112 if local.value_type != target.value_type {
113 return Err(McdError::from_diagnostic(
114 Diagnostic::error(
115 "schema.foreign_key.column.type_mismatch",
116 format!(
117 "Foreign key column '{}.{}' has type {:?}, but target '{}.{}' has type {:?}.",
118 table.id,
119 local_column,
120 local.value_type,
121 foreign_key.references.table,
122 target_column,
123 target.value_type
124 ),
125 )
126 .with_source(table.source.clone()),
127 ));
128 }
129 }
130
131 let target_keys = target
132 .rows
133 .iter()
134 .filter_map(|row| row_key(row, &foreign_key.references.columns))
135 .collect::<std::collections::HashSet<_>>();
136
137 for (row_index, row) in table.rows.iter().enumerate() {
138 let Some(key) = row_key(row, &foreign_key.columns) else {
139 continue;
140 };
141 if !target_keys.contains(&key) {
142 return Err(McdError::from_diagnostic(
143 Diagnostic::error(
144 "csv.foreign_key.unresolved",
145 format!(
146 "Foreign key in table '{}' references a missing row in table '{}'.",
147 table.id, foreign_key.references.table
148 ),
149 )
150 .with_source(format!(
151 "{}:{}",
152 table.source,
153 row_index + 2
154 )),
155 ));
156 }
157 }
158 }
159 }
160
161 Ok(())
162}
163
164fn load_and_validate_views(
165 package: &McdPackage,
166 manifest: &crate::Manifest,
167 tables: &IndexMap<String, DataTable>,
168) -> crate::Result<IndexMap<String, IndexMap<String, TableView>>> {
169 let mut all_views = IndexMap::new();
170
171 for table_entry in &manifest.tables {
172 let table = tables.get(&table_entry.id).ok_or_else(|| {
173 McdError::from_diagnostic(
174 Diagnostic::error(
175 "table.internal.missing",
176 format!(
177 "Loaded table '{}' was not available for view validation.",
178 table_entry.id
179 ),
180 )
181 .with_source("manifest.json"),
182 )
183 })?;
184 let mut table_views = IndexMap::new();
185
186 for (view_id, view_path) in &table_entry.views {
187 let view = TableView::from_package(package, view_path)?;
188 view.validate(view_id, &table_entry.id, &table.schema, view_path)?;
189 table_views.insert(view_id.clone(), view);
190 }
191
192 all_views.insert(table_entry.id.clone(), table_views);
193 }
194
195 Ok(all_views)
196}
197
198fn validate_table_anchors(
199 document: &McdDocument,
200 tables: &IndexMap<String, DataTable>,
201 views: &IndexMap<String, IndexMap<String, TableView>>,
202) -> crate::Result<()> {
203 for block in &document.blocks {
204 let DocumentBlock::TableRef {
205 placement, source, ..
206 } = block
207 else {
208 continue;
209 };
210
211 if !tables.contains_key(&placement.table) {
212 return Err(anchor_error(
213 "table.anchor.unresolved",
214 format!(
215 "Table anchor references undeclared table '{}'.",
216 placement.table
217 ),
218 document,
219 *source,
220 ));
221 }
222
223 let Some(view_id) = &placement.view else {
224 if placement.display == TableDisplay::Chart {
225 return Err(anchor_error(
226 "chart.view.missing",
227 "Chart table anchors must reference a chart view.",
228 document,
229 *source,
230 ));
231 }
232 continue;
233 };
234
235 let Some(table_views) = views.get(&placement.table) else {
236 return Err(anchor_error(
237 "view.anchor.unresolved",
238 format!(
239 "Table anchor references view '{}' but table '{}' declares no views.",
240 view_id, placement.table
241 ),
242 document,
243 *source,
244 ));
245 };
246 let Some(view) = table_views.get(view_id) else {
247 return Err(anchor_error(
248 if placement.display == TableDisplay::Chart {
249 "chart.view.unresolved"
250 } else {
251 "view.anchor.unresolved"
252 },
253 format!(
254 "Table anchor references unknown view '{}' for table '{}'.",
255 view_id, placement.table
256 ),
257 document,
258 *source,
259 ));
260 };
261
262 if placement.display == TableDisplay::Chart && view.display != TableDisplay::Chart {
263 return Err(anchor_error(
264 "chart.view.not_chart",
265 format!(
266 "Chart anchor references view '{}' but that view is not a chart view.",
267 view_id
268 ),
269 document,
270 *source,
271 ));
272 }
273 if placement.display == TableDisplay::Table && view.display != TableDisplay::Table {
274 return Err(anchor_error(
275 "view.display.mismatch",
276 format!(
277 "Table anchor references view '{}' but that view is a chart view.",
278 view_id
279 ),
280 document,
281 *source,
282 ));
283 }
284 if view.table != placement.table {
285 return Err(anchor_error(
286 "chart.table.mismatch",
287 format!(
288 "View '{}' references table '{}' but anchor references table '{}'.",
289 view_id, view.table, placement.table
290 ),
291 document,
292 *source,
293 ));
294 }
295 }
296
297 Ok(())
298}
299
300fn anchor_error(
301 code: impl Into<String>,
302 message: impl Into<String>,
303 document: &McdDocument,
304 source: Option<SourceSpan>,
305) -> McdError {
306 let source = source
307 .map(|span| format!("{}:{span}", document.source_path))
308 .unwrap_or_else(|| document.source_path.clone());
309 McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source))
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use std::io::{Cursor, Write};
316 use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
317
318 #[test]
319 fn validates_table_and_chart_package() {
320 let package = package_with(
321 "quarter,revenue_gbp\nQ1,125000.00\n",
322 r#"{"id":"revenue","columns":[
323 {"name":"quarter","type":"string","nullable":false},
324 {"name":"revenue_gbp","type":"decimal","nullable":false}
325 ]}"#,
326 r#"{"id":"default","table":"revenue","display":"table","columns":[{"name":"quarter"}]}"#,
327 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
328 "type":"bar",
329 "x":{"column":"quarter"},
330 "y":{"column":"revenue_gbp"}
331 }}"#,
332 Some(
333 ":::table\nref: t\ntable: revenue\nview: default\n:::\n\n:::table\nref: c\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
334 ),
335 );
336
337 validate_package(&package).expect("valid package");
338 }
339
340 #[test]
341 fn rejects_csv_header_mismatch() {
342 let package = package_with(
343 "quarter,amount\nQ1,125000.00\n",
344 r#"{"id":"revenue","columns":[
345 {"name":"quarter","type":"string"},
346 {"name":"revenue_gbp","type":"decimal"}
347 ]}"#,
348 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
349 chart_view(),
350 None,
351 );
352
353 let err = validate_package(&package).expect_err("package should be invalid");
354 let diagnostic = err.diagnostic().expect("structured diagnostic");
355 assert_eq!(diagnostic.level, crate::errors::DiagnosticLevel::Error);
356 assert_eq!(diagnostic.code, "csv.header.mismatch");
357 assert!(
358 diagnostic
359 .message
360 .contains("CSV header does not match table schema")
361 );
362 assert_eq!(diagnostic.source.as_deref(), Some("tables/revenue.csv:1"));
363 assert_eq!(diagnostic.related, vec!["tables/revenue.schema.json"]);
364 }
365
366 #[test]
367 fn rejects_unresolved_table_anchor() {
368 let package = package_with(
369 "quarter,revenue_gbp\nQ1,125000.00\n",
370 schema(),
371 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
372 chart_view(),
373 Some(":::table\ntable: missing\n:::\n"),
374 );
375
376 assert_validation_code(&package, "table.anchor.unresolved");
377 }
378
379 #[test]
380 fn rejects_unknown_view_column() {
381 let package = package_with(
382 "quarter,revenue_gbp\nQ1,125000.00\n",
383 schema(),
384 r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
385 chart_view(),
386 None,
387 );
388
389 assert_validation_code(&package, "view.column.unknown");
390 }
391
392 #[test]
393 fn rejects_unknown_chart_column() {
394 let package = package_with(
395 "quarter,revenue_gbp\nQ1,125000.00\n",
396 schema(),
397 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
398 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
399 "type":"bar",
400 "x":{"column":"quarter"},
401 "y":{"column":"missing"}
402 }}"#,
403 None,
404 );
405
406 assert_validation_code(&package, "chart.column.unknown");
407 }
408
409 #[test]
410 fn rejects_incompatible_chart_column_type() {
411 let package = package_with(
412 "quarter,revenue_gbp\nQ1,125000.00\n",
413 schema(),
414 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
415 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
416 "type":"bar",
417 "x":{"column":"revenue_gbp"},
418 "y":{"column":"quarter"}
419 }}"#,
420 None,
421 );
422
423 assert_validation_code(&package, "chart.column.type.incompatible");
424 }
425
426 #[test]
427 fn rejects_chart_anchor_referencing_non_chart_view() {
428 let package = package_with(
429 "quarter,revenue_gbp\nQ1,125000.00\n",
430 schema(),
431 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
432 chart_view(),
433 Some(":::table\ntable: revenue\nview: default\ndisplay: chart\n:::\n"),
434 );
435
436 assert_validation_code(&package, "chart.view.not_chart");
437 }
438
439 #[test]
440 fn validates_foreign_key_references() {
441 let package = package_with_related_tables("c1,Alice\n", "o1,c1\n");
442
443 validate_package(&package).expect("foreign key should validate");
444 }
445
446 #[test]
447 fn rejects_unresolved_foreign_key_row() {
448 let package = package_with_related_tables("c1,Alice\n", "o1,missing\n");
449
450 assert_validation_code(&package, "csv.foreign_key.unresolved");
451 }
452
453 #[test]
454 fn rejects_foreign_key_referencing_non_primary_key() {
455 let package = McdPackage::from_bytes(&zip_bytes(&[
456 ("mimetype", crate::package::MCD_MIMETYPE),
457 ("manifest.json", related_manifest()),
458 ("content/main.md", "# Orders\n"),
459 ("tables/customers.csv", "customer_id,name\nc1,Alice\n"),
460 (
461 "tables/customers.schema.json",
462 r#"{"id":"customers","primaryKey":["customer_id"],"columns":[
463 {"name":"customer_id","type":"string"},
464 {"name":"name","type":"string"}
465 ]}"#,
466 ),
467 ("tables/orders.csv", "order_id,customer_id\no1,c1\n"),
468 (
469 "tables/orders.schema.json",
470 r#"{"id":"orders","primaryKey":["order_id"],"foreignKeys":[{
471 "columns":["customer_id"],
472 "references":{"table":"customers","columns":["name"]}
473 }],"columns":[
474 {"name":"order_id","type":"string"},
475 {"name":"customer_id","type":"string"}
476 ]}"#,
477 ),
478 ]))
479 .expect("package opens");
480
481 assert_validation_code(
482 &package,
483 "schema.foreign_key.references.primary_key.mismatch",
484 );
485 }
486
487 #[test]
488 fn validates_image_package() {
489 let package = image_package(
490 safe_svg(),
491 image_metadata(
492 "diagram",
493 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
494 ),
495 ":::image\nasset: process-diagram\n:::\n",
496 r#""conformance":["MCD-Core","MCD-Images"]"#,
497 );
498
499 validate_package(&package).expect("valid image package");
500 }
501
502 #[test]
503 fn rejects_missing_image_asset() {
504 let package = image_package_without_asset(
505 image_metadata(
506 "diagram",
507 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
508 ),
509 ":::image\nasset: process-diagram\n:::\n",
510 r#""conformance":["MCD-Core","MCD-Images"]"#,
511 );
512
513 assert_validation_code(&package, "asset.missing");
514 }
515
516 #[test]
517 fn rejects_missing_informative_image_alt() {
518 let package = image_package(
519 safe_svg(),
520 image_metadata(
521 "informative",
522 r#""caption":"Workflow diagram.","hash":null"#,
523 ),
524 ":::image\nasset: process-diagram\n:::\n",
525 r#""conformance":["MCD-Core","MCD-Images"]"#,
526 );
527
528 assert_validation_code(&package, "image.alt.missing");
529 }
530
531 #[test]
532 fn rejects_unresolved_image_anchor() {
533 let package = image_package(
534 safe_svg(),
535 image_metadata(
536 "diagram",
537 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
538 ),
539 ":::image\nasset: missing-image\n:::\n",
540 r#""conformance":["MCD-Core","MCD-Images"]"#,
541 );
542
543 assert_validation_code(&package, "image.anchor.unresolved");
544 }
545
546 #[test]
547 fn rejects_svg_script_and_external_resource() {
548 let script_package = image_package(
549 r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
550 image_metadata(
551 "diagram",
552 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
553 ),
554 ":::image\nasset: process-diagram\n:::\n",
555 r#""conformance":["MCD-Core","MCD-Images"]"#,
556 );
557 assert_validation_code(&script_package, "security.svg.active_content");
558
559 let external_package = image_package(
560 r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.com/x.png"/></svg>"#,
561 image_metadata(
562 "diagram",
563 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
564 ),
565 ":::image\nasset: process-diagram\n:::\n",
566 r#""conformance":["MCD-Core","MCD-Images"]"#,
567 );
568 assert_validation_code(&external_package, "security.svg.external_reference");
569 }
570
571 #[test]
572 fn rejects_image_only_table_under_strict() {
573 let package = image_package(
574 safe_svg(),
575 r#"{
576 "id":"process-diagram",
577 "asset":"assets/process-diagram.svg",
578 "mediaType":"image/svg+xml",
579 "role":"diagram",
580 "alt":"Image of a table.",
581 "caption":"Table image.",
582 "meaningfulContent":{"tableData":true}
583 }"#
584 .to_owned(),
585 ":::image\nasset: process-diagram\n:::\n",
586 r#""conformance":["MCD-Core","MCD-Images","MCD-Strict"]"#,
587 );
588
589 assert_validation_code(&package, "image.meaningful_content.unlinked");
590 }
591
592 fn assert_validation_code(package: &McdPackage, code: &str) {
593 let err = validate_package(package).expect_err("package should be invalid");
594 assert_eq!(err.diagnostic().map(|d| d.code.as_str()), Some(code));
595 }
596
597 fn package_with(
598 csv: &str,
599 schema_json: &str,
600 table_view_json: &str,
601 chart_view_json: &str,
602 markdown: Option<&str>,
603 ) -> McdPackage {
604 let markdown = markdown.unwrap_or(
605 ":::table\ntable: revenue\nview: default\n:::\n\n:::table\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
606 );
607 McdPackage::from_bytes(&zip_bytes(&[
608 ("mimetype", crate::package::MCD_MIMETYPE),
609 ("manifest.json", manifest()),
610 ("content/main.md", markdown),
611 ("tables/revenue.csv", csv),
612 ("tables/revenue.schema.json", schema_json),
613 ("tables/revenue.view.json", table_view_json),
614 ("tables/revenue.chart.view.json", chart_view_json),
615 ]))
616 .expect("package opens")
617 }
618
619 fn image_package(
620 svg: &str,
621 image_json: String,
622 markdown: &str,
623 conformance: &str,
624 ) -> McdPackage {
625 McdPackage::from_bytes(&zip_bytes_owned(vec![
626 ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
627 ("manifest.json", image_manifest(conformance)),
628 ("content/main.md", markdown.to_owned()),
629 ("assets/process-diagram.svg", svg.to_owned()),
630 ("images/process-diagram.image.json", image_json),
631 ]))
632 .expect("package opens")
633 }
634
635 fn image_package_without_asset(
636 image_json: String,
637 markdown: &str,
638 conformance: &str,
639 ) -> McdPackage {
640 McdPackage::from_bytes(&zip_bytes_owned(vec![
641 ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
642 ("manifest.json", image_manifest(conformance)),
643 ("content/main.md", markdown.to_owned()),
644 ("images/process-diagram.image.json", image_json),
645 ]))
646 .expect("package opens")
647 }
648
649 fn image_manifest(conformance: &str) -> String {
650 format!(
651 r#"{{
652 "format":"MCD",
653 "version":"0.1",
654 "profile":"MCD-Core",
655 {conformance},
656 "entrypoint":"content/main.md",
657 "images":[{{"id":"process-diagram","metadata":"images/process-diagram.image.json"}}]
658 }}"#
659 )
660 }
661
662 fn image_metadata(role: &str, extra_fields: &str) -> String {
663 format!(
664 r#"{{
665 "id":"process-diagram",
666 "asset":"assets/process-diagram.svg",
667 "mediaType":"image/svg+xml",
668 "role":"{role}",
669 {extra_fields}
670 }}"#
671 )
672 }
673
674 fn safe_svg() -> &'static str {
675 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60"><rect width="120" height="60" fill="#f2f2f2"/><text x="10" y="35">Process</text></svg>"##
676 }
677
678 fn manifest() -> &'static str {
679 r#"{
680 "format":"MCD",
681 "version":"0.1",
682 "profile":"MCD-Core",
683 "entrypoint":"content/main.md",
684 "tables":[{
685 "id":"revenue",
686 "data":"tables/revenue.csv",
687 "schema":"tables/revenue.schema.json",
688 "views":{
689 "default":"tables/revenue.view.json",
690 "chart":"tables/revenue.chart.view.json"
691 }
692 }]
693 }"#
694 }
695
696 fn package_with_related_tables(customers_csv_rows: &str, orders_csv_rows: &str) -> McdPackage {
697 let customers_csv = format!("customer_id,name\n{customers_csv_rows}");
698 let orders_csv = format!("order_id,customer_id\n{orders_csv_rows}");
699 McdPackage::from_bytes(&zip_bytes_owned(vec![
700 ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
701 ("manifest.json", related_manifest().to_owned()),
702 ("content/main.md", "# Orders\n".to_owned()),
703 ("tables/customers.csv", customers_csv),
704 (
705 "tables/customers.schema.json",
706 r#"{"id":"customers","primaryKey":["customer_id"],"columns":[
707 {"name":"customer_id","type":"string"},
708 {"name":"name","type":"string"}
709 ]}"#
710 .to_owned(),
711 ),
712 ("tables/orders.csv", orders_csv),
713 (
714 "tables/orders.schema.json",
715 r#"{"id":"orders","primaryKey":["order_id"],"foreignKeys":[{
716 "columns":["customer_id"],
717 "references":{"table":"customers","columns":["customer_id"]}
718 }],"columns":[
719 {"name":"order_id","type":"string"},
720 {"name":"customer_id","type":"string"}
721 ]}"#
722 .to_owned(),
723 ),
724 ]))
725 .expect("package opens")
726 }
727
728 fn related_manifest() -> &'static str {
729 r#"{
730 "format":"MCD",
731 "version":"0.1",
732 "profile":"MCD-Core",
733 "entrypoint":"content/main.md",
734 "tables":[
735 {"id":"customers","data":"tables/customers.csv","schema":"tables/customers.schema.json"},
736 {"id":"orders","data":"tables/orders.csv","schema":"tables/orders.schema.json"}
737 ]
738 }"#
739 }
740
741 fn schema() -> &'static str {
742 r#"{"id":"revenue","columns":[
743 {"name":"quarter","type":"string"},
744 {"name":"revenue_gbp","type":"decimal"}
745 ]}"#
746 }
747
748 fn chart_view() -> &'static str {
749 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
750 "type":"bar",
751 "x":{"column":"quarter"},
752 "y":{"column":"revenue_gbp"}
753 }}"#
754 }
755
756 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
757 let cursor = Cursor::new(Vec::new());
758 let mut writer = ZipWriter::new(cursor);
759 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
760
761 for (path, content) in entries {
762 writer.start_file(*path, options).expect("start file");
763 writer.write_all(content.as_bytes()).expect("write file");
764 }
765
766 writer.finish().expect("finish zip").into_inner()
767 }
768
769 fn zip_bytes_owned(entries: Vec<(&str, String)>) -> Vec<u8> {
770 let cursor = Cursor::new(Vec::new());
771 let mut writer = ZipWriter::new(cursor);
772 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
773
774 for (path, content) in entries {
775 writer.start_file(path, options).expect("start file");
776 writer.write_all(content.as_bytes()).expect("write file");
777 }
778
779 writer.finish().expect("finish zip").into_inner()
780 }
781}