1use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6use time::{OffsetDateTime, format_description::well_known::Rfc3339};
7
8use crate::{
9 Manifest, McdPackage,
10 errors::{Diagnostic, McdError, Result},
11 manifest::{is_media_type, is_sha256, is_supported_external_uri, is_valid_id},
12 package::validate_internal_path,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct ProvenanceMetadata {
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
21 pub sources: Vec<ProvenanceSource>,
22 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub actors: Vec<ProvenanceActor>,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub tools: Vec<ProvenanceTool>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub generated_assets: Vec<GeneratedAsset>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub activities: Vec<ProvenanceActivity>,
34}
35
36impl ProvenanceMetadata {
37 pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
39 let bytes = package.read(path).map_err(|_| {
40 McdError::from_diagnostic(
41 Diagnostic::error(
42 "provenance.metadata.missing",
43 format!("Declared provenance metadata file '{path}' is missing."),
44 )
45 .with_source(path.to_owned()),
46 )
47 })?;
48 serde_json::from_slice::<Self>(bytes).map_err(McdError::from)
49 }
50
51 pub fn validate(&self, manifest: &Manifest, package: &McdPackage, source: &str) -> Result<()> {
53 let indexes = ProvenanceIndexes::new(self, manifest, source)?;
54
55 for item in &self.sources {
56 item.validate(package, source)?;
57 }
58 for item in &self.actors {
59 item.validate(source)?;
60 }
61 for item in &self.tools {
62 item.validate(source)?;
63 }
64 for item in &self.generated_assets {
65 item.validate(package, &indexes, source)?;
66 }
67 for item in &self.activities {
68 item.validate(&indexes, source)?;
69 }
70
71 Ok(())
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct ProvenanceSource {
79 pub id: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub path: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub uri: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub media_type: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub hash: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub size_bytes: Option<u64>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub title: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub created_at: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub retrieved_at: Option<String>,
105}
106
107impl ProvenanceSource {
108 fn validate(&self, package: &McdPackage, source: &str) -> Result<()> {
109 validate_id("provenance.source.id.invalid", &self.id, source)?;
110 if self.path.is_none() && self.uri.is_none() {
111 return Err(provenance_error(
112 "provenance.source.location.missing",
113 format!("Provenance source '{}' must declare path or uri.", self.id),
114 source,
115 ));
116 }
117 if let Some(path) = &self.path {
118 validate_package_path("provenance.source.path.invalid", path, source)?;
119 if !package.contains(path) {
120 return Err(provenance_error(
121 "provenance.source.path.missing",
122 format!(
123 "Provenance source '{}' references missing package path '{}'.",
124 self.id, path
125 ),
126 source,
127 ));
128 }
129 }
130 if let Some(uri) = &self.uri {
131 validate_uri("provenance.source.uri.invalid", uri, source)?;
132 }
133 validate_optional_media_type(
134 &self.media_type,
135 "provenance.source.media_type.invalid",
136 source,
137 )?;
138 validate_optional_hash(&self.hash, "provenance.source.hash.invalid", source)?;
139 validate_optional_text(&self.title, "provenance.source.title.empty", source)?;
140 validate_optional_timestamp(
141 &self.created_at,
142 "provenance.source.created_at.invalid",
143 source,
144 )?;
145 validate_optional_timestamp(
146 &self.retrieved_at,
147 "provenance.source.retrieved_at.invalid",
148 source,
149 )?;
150 Ok(())
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ProvenanceActor {
158 pub id: String,
160 pub kind: ActorKind,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub name: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub uri: Option<String>,
168}
169
170impl ProvenanceActor {
171 fn validate(&self, source: &str) -> Result<()> {
172 validate_id("provenance.actor.id.invalid", &self.id, source)?;
173 validate_optional_text(&self.name, "provenance.actor.name.empty", source)?;
174 if let Some(uri) = &self.uri {
175 validate_uri("provenance.actor.uri.invalid", uri, source)?;
176 }
177 Ok(())
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum ActorKind {
185 Person,
187 Organization,
189 Software,
191 Agent,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct ProvenanceTool {
199 pub id: String,
201 pub name: String,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub version: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub uri: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub hash: Option<String>,
212}
213
214impl ProvenanceTool {
215 fn validate(&self, source: &str) -> Result<()> {
216 validate_id("provenance.tool.id.invalid", &self.id, source)?;
217 if self.name.trim().is_empty() {
218 return Err(provenance_error(
219 "provenance.tool.name.empty",
220 "Provenance tool name cannot be empty.",
221 source,
222 ));
223 }
224 validate_optional_text(&self.version, "provenance.tool.version.empty", source)?;
225 if let Some(uri) = &self.uri {
226 validate_uri("provenance.tool.uri.invalid", uri, source)?;
227 }
228 validate_optional_hash(&self.hash, "provenance.tool.hash.invalid", source)?;
229 Ok(())
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct GeneratedAsset {
237 pub id: String,
239 pub path: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub media_type: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub hash: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub created_at: Option<String>,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub source_refs: Vec<String>,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub tool_refs: Vec<String>,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub actor_refs: Vec<String>,
259}
260
261impl GeneratedAsset {
262 fn validate(
263 &self,
264 package: &McdPackage,
265 indexes: &ProvenanceIndexes,
266 source: &str,
267 ) -> Result<()> {
268 validate_id("provenance.generated_asset.id.invalid", &self.id, source)?;
269 validate_package_path(
270 "provenance.generated_asset.path.invalid",
271 &self.path,
272 source,
273 )?;
274 if !package.contains(&self.path) {
275 return Err(provenance_error(
276 "provenance.generated_asset.path.missing",
277 format!(
278 "Generated asset '{}' references missing package path '{}'.",
279 self.id, self.path
280 ),
281 source,
282 ));
283 }
284 validate_optional_media_type(
285 &self.media_type,
286 "provenance.generated_asset.media_type.invalid",
287 source,
288 )?;
289 validate_optional_hash(
290 &self.hash,
291 "provenance.generated_asset.hash.invalid",
292 source,
293 )?;
294 validate_optional_timestamp(
295 &self.created_at,
296 "provenance.generated_asset.created_at.invalid",
297 source,
298 )?;
299 validate_refs(
300 "provenance.generated_asset.source_ref.unresolved",
301 &self.source_refs,
302 &indexes.sources,
303 "source",
304 source,
305 )?;
306 validate_refs(
307 "provenance.generated_asset.tool_ref.unresolved",
308 &self.tool_refs,
309 &indexes.tools,
310 "tool",
311 source,
312 )?;
313 validate_refs(
314 "provenance.generated_asset.actor_ref.unresolved",
315 &self.actor_refs,
316 &indexes.actors,
317 "actor",
318 source,
319 )?;
320 Ok(())
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct ProvenanceActivity {
328 pub id: String,
330 pub kind: ActivityKind,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub started_at: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub ended_at: Option<String>,
338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
340 pub actor_refs: Vec<String>,
341 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub tool_refs: Vec<String>,
344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub source_refs: Vec<String>,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub input_refs: Vec<String>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub output_refs: Vec<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub description: Option<String>,
356}
357
358impl ProvenanceActivity {
359 fn validate(&self, indexes: &ProvenanceIndexes, source: &str) -> Result<()> {
360 validate_id("provenance.activity.id.invalid", &self.id, source)?;
361 let started_at = validate_optional_timestamp(
362 &self.started_at,
363 "provenance.activity.started_at.invalid",
364 source,
365 )?;
366 let ended_at = validate_optional_timestamp(
367 &self.ended_at,
368 "provenance.activity.ended_at.invalid",
369 source,
370 )?;
371 if let (Some(started_at), Some(ended_at)) = (started_at, ended_at)
372 && ended_at < started_at
373 {
374 return Err(provenance_error(
375 "provenance.activity.time_order.invalid",
376 format!("Provenance activity '{}' ends before it starts.", self.id),
377 source,
378 ));
379 }
380 validate_refs(
381 "provenance.activity.actor_ref.unresolved",
382 &self.actor_refs,
383 &indexes.actors,
384 "actor",
385 source,
386 )?;
387 validate_refs(
388 "provenance.activity.tool_ref.unresolved",
389 &self.tool_refs,
390 &indexes.tools,
391 "tool",
392 source,
393 )?;
394 validate_refs(
395 "provenance.activity.source_ref.unresolved",
396 &self.source_refs,
397 &indexes.sources,
398 "source",
399 source,
400 )?;
401 validate_generic_refs(
402 "provenance.activity.input_ref.invalid",
403 &self.input_refs,
404 indexes,
405 source,
406 )?;
407 validate_generic_refs(
408 "provenance.activity.output_ref.invalid",
409 &self.output_refs,
410 indexes,
411 source,
412 )?;
413 validate_optional_text(
414 &self.description,
415 "provenance.activity.description.empty",
416 source,
417 )?;
418 Ok(())
419 }
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "snake_case")]
425pub enum ActivityKind {
426 Authored,
428 Imported,
430 Extracted,
432 Transformed,
434 Generated,
436 Validated,
438 Rendered,
440 Reviewed,
442}
443
444pub fn load_manifest_provenance(
446 package: &McdPackage,
447 manifest: &Manifest,
448) -> Result<Option<ProvenanceMetadata>> {
449 let Some(path) = &manifest.provenance else {
450 return Ok(None);
451 };
452 let metadata = ProvenanceMetadata::from_package(package, path)?;
453 metadata.validate(manifest, package, path)?;
454 Ok(Some(metadata))
455}
456
457struct ProvenanceIndexes {
458 sources: HashSet<String>,
459 actors: HashSet<String>,
460 tools: HashSet<String>,
461 generated_assets: HashSet<String>,
462 tables: HashSet<String>,
463 images: HashSet<String>,
464 annotations: HashSet<String>,
465 external_data: HashSet<String>,
466 assets: HashSet<String>,
467}
468
469impl ProvenanceIndexes {
470 fn new(metadata: &ProvenanceMetadata, manifest: &Manifest, source: &str) -> Result<Self> {
471 let sources = collect_ids(
472 "provenance.source.id.duplicate",
473 metadata.sources.iter().map(|item| item.id.as_str()),
474 "source",
475 source,
476 )?;
477 let actors = collect_ids(
478 "provenance.actor.id.duplicate",
479 metadata.actors.iter().map(|item| item.id.as_str()),
480 "actor",
481 source,
482 )?;
483 let tools = collect_ids(
484 "provenance.tool.id.duplicate",
485 metadata.tools.iter().map(|item| item.id.as_str()),
486 "tool",
487 source,
488 )?;
489 let generated_assets = collect_ids(
490 "provenance.generated_asset.id.duplicate",
491 metadata
492 .generated_assets
493 .iter()
494 .map(|item| item.id.as_str()),
495 "generated asset",
496 source,
497 )?;
498 collect_ids(
499 "provenance.activity.id.duplicate",
500 metadata.activities.iter().map(|item| item.id.as_str()),
501 "activity",
502 source,
503 )?;
504
505 Ok(Self {
506 sources,
507 actors,
508 tools,
509 generated_assets,
510 tables: manifest.tables.iter().map(|item| item.id.clone()).collect(),
511 images: manifest.images.iter().map(|item| item.id.clone()).collect(),
512 annotations: manifest
513 .annotations
514 .iter()
515 .map(|item| item.id.clone())
516 .collect(),
517 external_data: manifest
518 .external_data
519 .iter()
520 .map(|item| item.id.clone())
521 .collect(),
522 assets: manifest
523 .assets
524 .iter()
525 .filter_map(|item| item.id.clone())
526 .collect(),
527 })
528 }
529}
530
531fn collect_ids<'a>(
532 code: &'static str,
533 ids: impl Iterator<Item = &'a str>,
534 label: &str,
535 source: &str,
536) -> Result<HashSet<String>> {
537 let mut seen = HashSet::new();
538 for id in ids {
539 if !seen.insert(id.to_owned()) {
540 return Err(provenance_error(
541 code,
542 format!("Duplicate provenance {label} id '{id}'."),
543 source,
544 ));
545 }
546 }
547 Ok(seen)
548}
549
550fn validate_id(code: &'static str, id: &str, source: &str) -> Result<()> {
551 if !is_valid_id(id) {
552 return Err(provenance_error(
553 code,
554 format!("Invalid provenance id '{id}'."),
555 source,
556 ));
557 }
558 Ok(())
559}
560
561fn validate_package_path(code: &'static str, path: &str, source: &str) -> Result<()> {
562 validate_internal_path(path).map(|_| ()).map_err(|_| {
563 provenance_error(
564 code,
565 format!("Invalid provenance package path '{path}'."),
566 source,
567 )
568 })
569}
570
571fn validate_uri(code: &'static str, uri: &str, source: &str) -> Result<()> {
572 if !is_supported_external_uri(uri) {
573 return Err(provenance_error(
574 code,
575 format!("Invalid provenance URI '{uri}'."),
576 source,
577 ));
578 }
579 Ok(())
580}
581
582fn validate_optional_media_type(
583 value: &Option<String>,
584 code: &'static str,
585 source: &str,
586) -> Result<()> {
587 if let Some(value) = value
588 && !is_media_type(value)
589 {
590 return Err(provenance_error(
591 code,
592 format!("Invalid provenance media type '{value}'."),
593 source,
594 ));
595 }
596 Ok(())
597}
598
599fn validate_optional_hash(value: &Option<String>, code: &'static str, source: &str) -> Result<()> {
600 if let Some(value) = value
601 && !is_sha256(value)
602 {
603 return Err(provenance_error(
604 code,
605 "Provenance hash must use sha256:<64 lowercase hex characters>.",
606 source,
607 ));
608 }
609 Ok(())
610}
611
612fn validate_optional_text(value: &Option<String>, code: &'static str, source: &str) -> Result<()> {
613 if value
614 .as_deref()
615 .is_some_and(|value| value.trim().is_empty())
616 {
617 return Err(provenance_error(
618 code,
619 "Provenance text fields cannot be empty.",
620 source,
621 ));
622 }
623 Ok(())
624}
625
626fn validate_optional_timestamp(
627 value: &Option<String>,
628 code: &'static str,
629 source: &str,
630) -> Result<Option<OffsetDateTime>> {
631 let Some(value) = value else {
632 return Ok(None);
633 };
634 OffsetDateTime::parse(value, &Rfc3339)
635 .map(Some)
636 .map_err(|_| {
637 provenance_error(
638 code,
639 format!("Provenance timestamp '{value}' must be RFC 3339 date-time."),
640 source,
641 )
642 })
643}
644
645fn validate_refs(
646 code: &'static str,
647 refs: &[String],
648 allowed: &HashSet<String>,
649 label: &str,
650 source: &str,
651) -> Result<()> {
652 let mut seen = HashSet::new();
653 for reference in refs {
654 if reference.trim().is_empty() {
655 return Err(provenance_error(
656 code,
657 format!("Provenance {label} reference cannot be empty."),
658 source,
659 ));
660 }
661 if !seen.insert(reference) {
662 return Err(provenance_error(
663 code,
664 format!("Duplicate provenance {label} reference '{reference}'."),
665 source,
666 ));
667 }
668 if !allowed.contains(reference) {
669 return Err(provenance_error(
670 code,
671 format!("Provenance {label} reference '{reference}' is unresolved."),
672 source,
673 ));
674 }
675 }
676 Ok(())
677}
678
679fn validate_generic_refs(
680 code: &'static str,
681 refs: &[String],
682 indexes: &ProvenanceIndexes,
683 source: &str,
684) -> Result<()> {
685 let mut seen = HashSet::new();
686 for reference in refs {
687 if reference.trim().is_empty() {
688 return Err(provenance_error(
689 code,
690 "Provenance activity references cannot be empty.",
691 source,
692 ));
693 }
694 if !seen.insert(reference) {
695 return Err(provenance_error(
696 code,
697 format!("Duplicate provenance activity reference '{reference}'."),
698 source,
699 ));
700 }
701 if let Some((kind, id)) = reference.split_once(':') {
702 validate_typed_ref(code, kind, id, indexes, source)?;
703 }
704 }
705 Ok(())
706}
707
708fn validate_typed_ref(
709 code: &'static str,
710 kind: &str,
711 id: &str,
712 indexes: &ProvenanceIndexes,
713 source: &str,
714) -> Result<()> {
715 if id.trim().is_empty() {
716 return Err(provenance_error(
717 code,
718 "Typed provenance references must include an id.",
719 source,
720 ));
721 }
722
723 let resolved = match kind {
724 "source" => indexes.sources.contains(id),
725 "actor" => indexes.actors.contains(id),
726 "tool" => indexes.tools.contains(id),
727 "generatedAsset" | "generated_asset" => indexes.generated_assets.contains(id),
728 "table" => indexes.tables.contains(id),
729 "image" => indexes.images.contains(id),
730 "annotation" => indexes.annotations.contains(id),
731 "externalData" | "external_data" => indexes.external_data.contains(id),
732 "asset" => indexes.assets.contains(id),
733 "path" => validate_internal_path(id).is_ok(),
734 _ => true,
735 };
736
737 if !resolved {
738 return Err(provenance_error(
739 code,
740 format!("Typed provenance reference '{kind}:{id}' is unresolved."),
741 source,
742 ));
743 }
744 Ok(())
745}
746
747fn provenance_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
748 McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use std::io::{Cursor, Write};
755 use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
756
757 #[test]
758 fn loads_and_validates_provenance_metadata() {
759 let package = package_with_provenance(valid_provenance());
760 let manifest = package.manifest().expect("manifest");
761 let provenance = load_manifest_provenance(&package, &manifest)
762 .expect("provenance loads")
763 .expect("provenance is present");
764
765 assert_eq!(provenance.sources[0].id, "source-pdf");
766 assert_eq!(provenance.generated_assets[0].path, "content/main.md");
767 }
768
769 #[test]
770 fn rejects_unresolved_provenance_source_reference() {
771 let package = package_with_provenance(
772 r#"{
773 "generatedAssets":[{
774 "id":"main-md",
775 "path":"content/main.md",
776 "sourceRefs":["missing-source"]
777 }]
778 }"#,
779 );
780 let manifest = package.manifest().expect("manifest");
781 let err = load_manifest_provenance(&package, &manifest)
782 .expect_err("unresolved source reference should fail");
783
784 assert_eq!(
785 err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
786 Some("provenance.generated_asset.source_ref.unresolved")
787 );
788 }
789
790 #[test]
791 fn rejects_invalid_provenance_timestamp() {
792 let package = package_with_provenance(
793 r#"{
794 "sources":[{
795 "id":"source-pdf",
796 "path":"assets/source.pdf",
797 "createdAt":"2026-05-26"
798 }]
799 }"#,
800 );
801 let manifest = package.manifest().expect("manifest");
802 let err = load_manifest_provenance(&package, &manifest)
803 .expect_err("date without time zone should fail");
804
805 assert_eq!(
806 err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
807 Some("provenance.source.created_at.invalid")
808 );
809 }
810
811 fn valid_provenance() -> &'static str {
812 r#"{
813 "sources":[{
814 "id":"source-pdf",
815 "path":"assets/source.pdf",
816 "mediaType":"application/pdf",
817 "hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000",
818 "createdAt":"2026-05-26T10:00:00Z"
819 }],
820 "actors":[{"id":"agent-1","kind":"agent","name":"Extraction agent"}],
821 "tools":[{"id":"extractor","name":"mcd-pdf-extract","version":"0.1.0"}],
822 "generatedAssets":[{
823 "id":"main-md",
824 "path":"content/main.md",
825 "mediaType":"text/markdown",
826 "createdAt":"2026-05-26T10:01:00Z",
827 "sourceRefs":["source-pdf"],
828 "toolRefs":["extractor"],
829 "actorRefs":["agent-1"]
830 }],
831 "activities":[{
832 "id":"extract-1",
833 "kind":"extracted",
834 "startedAt":"2026-05-26T10:00:00Z",
835 "endedAt":"2026-05-26T10:01:00Z",
836 "sourceRefs":["source-pdf"],
837 "toolRefs":["extractor"],
838 "actorRefs":["agent-1"],
839 "inputRefs":["source:source-pdf"],
840 "outputRefs":["generatedAsset:main-md", "path:content/main.md"]
841 }]
842 }"#
843 }
844
845 fn package_with_provenance(provenance: &str) -> McdPackage {
846 McdPackage::from_bytes(&zip_bytes(&[
847 ("mimetype", crate::package::MCD_MIMETYPE),
848 (
849 "manifest.json",
850 r#"{
851 "format":"MCD",
852 "version":"0.1",
853 "profile":"MCD-Core",
854 "entrypoint":"content/main.md",
855 "assets":[{"id":"source-pdf","path":"assets/source.pdf"}],
856 "provenance":"provenance/provenance.json"
857 }"#,
858 ),
859 ("content/main.md", "# Source\n"),
860 ("assets/source.pdf", "%PDF-1.7\n"),
861 ("provenance/provenance.json", provenance),
862 ]))
863 .expect("package opens")
864 }
865
866 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
867 let cursor = Cursor::new(Vec::new());
868 let mut writer = ZipWriter::new(cursor);
869 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
870
871 for (path, content) in entries {
872 writer.start_file(*path, options).expect("start file");
873 writer.write_all(content.as_bytes()).expect("write file");
874 }
875
876 writer.finish().expect("finish zip").into_inner()
877 }
878}