c2pa-rs

A fork of https://github.com/contentauth/c2pa-rs/
git clone git://archive.git.mtrnord.blog/mtrnords-photography-manager/c2pa-rs.git
Log | Files | Refs | README

commit 84c55283facf0cd6c26c059fa0846c9b633ad2e7
parent aadbb3227d388e526bc1e1161481fc0c9eb1481a
Author: Gavin  Peacock <gpeacock@adobe.com>
Date:   Tue, 31 Jan 2023 15:25:18 -0800

(MINOR) Create a ResourceStore for binary assets  (#180)

* ResourceStore for binary file or in memory
Can read and write files thumbs and manifests

* integrate into Manifest and Ingredient

* Manifest
      thumbnail field is now a ResourceRef
      thumbnail() returns  Option<(&str, Cow<Vec<u8>>)> 
      added resources field, resources() and resources_mut()
      added thumbnail_ref() and set_thumbnail_ref()
      added ingredients_mut()
      new defaults for claim_generator, instance_id and format
      added from_json() // create manifest from json string
      added with_base_path() // sets manifest to be file based
      added fmt for to_string() - as pretty json
      
* Ingredient

* move thumbnails into ResourceStore

* add resources to ingredient

* add folder support

* from_json manifest and ingredient paths working
with_files and without

* Assign resources to embedded ingredients

* Add some defaults for Json deserializing

* reading and writing thumbnail images is now done via ResourceStore

* Reduce level of API change
manifest_data now uses resource_ref
Added exists to ResourceStore

* make filenames from instanceId/manifest labels
add default claim_generator
add parent function on manifest

* updates for rust v1.67

Diffstat:
Msdk/src/assertion.rs | 2+-
Msdk/src/ingredient.rs | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msdk/src/lib.rs | 5++++-
Msdk/src/manifest.rs | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msdk/src/manifest_store.rs | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msdk/src/manifest_store_report.rs | 6+++++-
Asdk/src/resource_store.rs | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 858 insertions(+), 121 deletions(-)

diff --git a/sdk/src/assertion.rs b/sdk/src/assertion.rs @@ -641,7 +641,7 @@ pub mod tests { Action::new("c2pa.cropped") .set_parameter( "coordinate".to_owned(), - r#"{"left": 0,"right": 2000,"top": 1000,"botton": 4000}"#, + serde_json::json!({"left": 0,"right": 2000,"top": 1000,"bottom": 4000}), ) .unwrap(), ) diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs @@ -12,11 +12,13 @@ // each license. #![deny(missing_docs)] - -use std::ops::Deref; +use std::borrow::Cow; +#[cfg(feature = "file_io")] +use std::path::{Path, PathBuf}; use log::{debug, error}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::{ assertion::{get_thumbnail_image_type, Assertion, AssertionBase}, @@ -25,27 +27,20 @@ use crate::{ error::{Error, Result}, hashed_uri::HashedUri, jumbf, + resource_store::{skip_serializing_resources, ResourceRef, ResourceStore}, store::Store, validation_status::{self, ValidationStatus}, }; #[cfg(feature = "file_io")] use crate::{error::wrap_io_err, validation_status::status_for_store, xmp_inmemory_utils::XmpInfo}; - -/// Function that is used by serde to determine whether or not we should serialize -/// thumbnail data based on the "serialize_thumbnails" flag (serialization is disabled by default) -fn skip_serializing_thumbnails(value: &Option<(String, Vec<u8>)>) -> bool { - !cfg!(feature = "serialize_thumbnails") || value.is_none() -} - -#[cfg(feature = "file_io")] -use std::path::Path; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Default, Deserialize, Serialize)] /// An `Ingredient` is any external asset that has been used in the creation of an image. pub struct Ingredient { /// A human-readable title, generally source filename. title: String, /// The format of the source file as a MIME type. + #[serde(default = "default_format")] format: String, /// Document ID from `xmpMM:DocumentID` in XMP metadata. @@ -53,6 +48,7 @@ pub struct Ingredient { document_id: Option<String>, /// Instance ID from `xmpMM:InstanceID` in XMP metadata. + #[serde(default = "default_instance_id")] instance_id: String, /// URI from `dcterms:provenance` in XMP metadata. @@ -62,8 +58,8 @@ pub struct Ingredient { /// A thumbnail image capturing the visual state at the time of import. /// /// A tuple of thumbnail MIME format (i.e. `image/jpeg`) and binary bits of the image. - #[serde(skip_serializing_if = "skip_serializing_thumbnails")] - thumbnail: Option<(String, Vec<u8>)>, + #[serde(skip_serializing_if = "Option::is_none")] + thumbnail: Option<ResourceRef>, /// An optional hash of the asset to prevent duplicates. #[serde(skip_serializing_if = "Option::is_none")] @@ -98,8 +94,20 @@ pub struct Ingredient { /// A [`ManifestStore`] from the source asset extracted as a binary C2PA blob. /// /// [`ManifestStore`]: crate::ManifestStore - #[serde(skip_serializing)] - manifest_data: Option<Vec<u8>>, + #[serde(skip_serializing_if = "Option::is_none")] + manifest_data: Option<ResourceRef>, + + #[serde(skip_deserializing)] + #[serde(skip_serializing_if = "skip_serializing_resources")] + resources: ResourceStore, +} + +fn default_instance_id() -> String { + format!("xmp:iid:{}", Uuid::new_v4()) +} + +fn default_format() -> String { + "application/octet-stream".to_owned() } impl Ingredient { @@ -124,16 +132,8 @@ impl Ingredient { Self { title: title.into(), format: format.into(), - document_id: None, instance_id: instance_id.into(), - provenance: None, - thumbnail: None, - hash: None, - is_parent: None, - validation_status: None, - metadata: None, - active_manifest: None, - manifest_data: None, + ..Default::default() } } @@ -162,11 +162,26 @@ impl Ingredient { self.provenance.as_deref() } - /// Returns a tuple with thumbnail format and image bytes or `None`. - pub fn thumbnail(&self) -> Option<(&str, &[u8])> { + /// Returns a ResourceRef or `None`. + pub fn thumbnail_ref(&self) -> Option<&ResourceRef> { + self.thumbnail.as_ref() + } + + /// Returns thumbnail tuple Some((content_type, bytes)) or None + /// + pub fn thumbnail(&self) -> Option<(&str, Cow<Vec<u8>>)> { self.thumbnail .as_ref() - .map(|(format, image)| (format.as_str(), image.deref())) + .and_then(|t| Some(t.content_type.as_str()).zip(self.resources.get(&t.identifier).ok())) + } + + /// Returns a Cow of thumbnail bytes or Err(Error::NotFound)`. + /// + pub fn thumbnail_bytes(&self) -> Result<Cow<Vec<u8>>> { + match self.thumbnail.as_ref() { + Some(thumbnail) => self.resources.get(&thumbnail.identifier), + None => Err(Error::NotFound), + } } /// Returns an optional hash to uniquely identify this asset @@ -201,9 +216,18 @@ impl Ingredient { /// Returns a reference to C2PA manifest data if it exists. /// - /// This is the binary form of a manifest store in .c2pa format. - pub fn manifest_data(&self) -> Option<&[u8]> { - self.manifest_data.as_deref() + /// manifest_data is the binary form of a manifest store in .c2pa format. + pub fn manifest_data_ref(&self) -> Option<&ResourceRef> { + self.manifest_data.as_ref() + } + + /// Returns a copy on write ref to the manifest data bytes or None`. + /// + /// manifest_data is the binary form of a manifest store in .c2pa format. + pub fn manifest_data(&self) -> Option<Cow<Vec<u8>>> { + self.manifest_data + .as_ref() + .and_then(|r| self.resources.get(&r.identifier).ok()) } /// Sets a human-readable title for this ingredient. @@ -241,10 +265,28 @@ impl Ingredient { self } - /// Sets the thumbnail format and image data. - pub fn set_thumbnail<S: Into<String>>(&mut self, format: S, thumbnail: Vec<u8>) -> &mut Self { - self.thumbnail = Some((format.into(), thumbnail)); - self + /// Sets the thumbnail from a ResourceRef. + pub fn set_thumbnail_ref(&mut self, thumbnail: ResourceRef) -> Result<&mut Self> { + // verify the resource referenced exists + if !self.resources.exists(&thumbnail.identifier) { + return Err(Error::NotFound); + }; + self.thumbnail = Some(thumbnail); + Ok(self) + } + + /// Sets the thumbnail content_type and image data. + pub fn set_thumbnail<S: Into<String>, B: Into<Vec<u8>>>( + &mut self, + content_type: S, + bytes: B, + ) -> Result<&mut Self> { + let base_id = self.instance_id().to_string(); + self.thumbnail = Some( + self.resources + .add_with(&base_id, &content_type.into(), bytes)?, + ); + Ok(self) } /// Sets the hash value generated from the entire asset. @@ -274,10 +316,31 @@ impl Ingredient { self } - /// Sets the Manifest C2PA data for this ingredient. - pub fn set_manifest_data(&mut self, data: Vec<u8>) -> &mut Self { - self.manifest_data = Some(data); - self + /// Sets a reference to Manifest C2PA data - does not verify the resource exists + pub fn set_manifest_data_ref(&mut self, data_ref: ResourceRef) -> Result<&mut Self> { + // verify the resource referenced exists + if !self.resources.exists(&data_ref.identifier) { + return Err(Error::NotFound); + }; + self.manifest_data = Some(data_ref); + Ok(self) + } + + /// Sets the Manifest C2PA data for this ingredient with bytes + pub fn set_manifest_data(&mut self, data: Vec<u8>) -> Result<&mut Self> { + let base_id = self.instance_id().to_string(); + self.manifest_data = Some(self.resources.add_with(&base_id, "c2pa", data)?); + Ok(self) + } + + /// Return an immutable reference to the ingredient resources + pub fn resources(&self) -> &ResourceStore { + &self.resources + } + + /// Return an mutable reference to the ingredient resources + pub fn resources_mut(&mut self) -> &mut ResourceStore { + &mut self.resources } /// Gathers filename, extension, and format from a file path. @@ -334,7 +397,6 @@ impl Ingredient { #[cfg(feature = "file_io")] pub fn from_file_info<P: AsRef<Path>>(path: P) -> Self { fn make_id(id_type: &str) -> String { - use uuid::Uuid; let uuid = Uuid::new_v4(); //warn!("Generating fake id {}", uuid); format!("xmp:{id_type}id:{uuid}") @@ -362,7 +424,18 @@ impl Ingredient { #[cfg(feature = "file_io")] /// Creates an `Ingredient` from a file path. pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> { - Self::from_file_with_options(path.as_ref(), &DefaultOptions {}) + Self::from_file_with_options(path.as_ref(), &DefaultOptions { base: None }) + } + + #[cfg(feature = "file_io")] + /// Creates an `Ingredient` from a file path. + pub fn from_file_with_folder<P: AsRef<Path>>(path: P, folder: P) -> Result<Self> { + Self::from_file_with_options( + path.as_ref(), + &DefaultOptions { + base: Some(PathBuf::from(folder.as_ref())), + }, + ) } fn thumbnail_from_assertion(assertion: &Assertion) -> (String, Vec<u8>) { @@ -402,6 +475,11 @@ impl Ingredient { return Err(Error::FileNotFound(ingredient.title)); } + // configure for writing to folders if that option is set + if let Some(folder) = options.base_path().as_ref() { + ingredient.with_base_path(folder)?; + } + // if options includes a title, use it if let Some(opt_title) = options.title(path) { ingredient.title = opt_title; @@ -455,12 +533,15 @@ impl Ingredient { { let (format, image) = Self::thumbnail_from_assertion(claim_assertion.assertion()); - ingredient.set_thumbnail(format, image); + ingredient.set_thumbnail(format, image)?; } } ingredient.active_manifest = Some(claim.label().to_string()); } - ingredient.manifest_data = manifest_bytes; + if let Some(bytes) = manifest_bytes { + ingredient.set_manifest_data(bytes)?; + } + ingredient.validation_status = if statuses.is_empty() { None } else { @@ -492,7 +573,7 @@ impl Ingredient { // create a thumbnail if we don't already have a manifest with a thumb we can use if ingredient.thumbnail.is_none() { if let Some((format, image)) = options.thumbnail(path) { - ingredient.set_thumbnail(format, image); + ingredient.set_thumbnail(format, image)?; } } @@ -500,7 +581,11 @@ impl Ingredient { } /// Creates an Ingredient from a store and a URI to an ingredient assertion. - pub(crate) fn from_ingredient_uri(store: &Store, ingredient_uri: &str) -> Result<Self> { + pub(crate) fn from_ingredient_uri( + store: &Store, + ingredient_uri: &str, + #[cfg(feature = "file_io")] resource_path: Option<&Path>, + ) -> Result<Self> { let assertion = store .get_assertion_from_uri(ingredient_uri) @@ -554,8 +639,14 @@ impl Ingredient { &ingredient_assertion.instance_id, ); ingredient.document_id = ingredient_assertion.document_id; + + #[cfg(feature = "file_io")] + if let Some(base_path) = resource_path { + ingredient.resources_mut().set_base_path(base_path) + } + if let Some((format, image)) = thumbnail { - ingredient.set_thumbnail(format, image); + ingredient.set_thumbnail(format, image)?; } ingredient.is_parent = is_parent; @@ -596,7 +687,7 @@ impl Ingredient { }; // have Store check and load ingredients and add them to a claim - Store::load_ingredient_to_claim(claim, &manifest_label, buffer, redactions)?; + Store::load_ingredient_to_claim(claim, &manifest_label, &buffer, redactions)?; // get the ingredient map loaded in previous match claim.claim_ingredient(&manifest_label) { @@ -656,10 +747,10 @@ impl Ingredient { // add ingredient thumbnail assertion if one is given and we don't already have one from the parent claim if thumbnail.is_none() { - if let Some((format, image)) = &self.thumbnail() { + if let Some((format, data)) = self.thumbnail() { let hash_url = claim.add_assertion(&Thumbnail::new( &labels::add_thumbnail_format(labels::INGREDIENT_THUMBNAIL, format), - image.to_vec(), + data.to_vec(), ))?; thumbnail = Some(hash_url); @@ -680,6 +771,16 @@ impl Ingredient { ingredient_assertion.validation_status = self.validation_status.clone(); claim.add_assertion(&ingredient_assertion) } + + /// Setting a base path will make the ingredient use resource files instead of memory buffers + /// + /// The files will be relative to the given base path + #[cfg(feature = "file_io")] + pub fn with_base_path<P: AsRef<Path>>(&mut self, base_path: P) -> Result<&Self> { + std::fs::create_dir_all(&base_path)?; + self.resources.set_base_path(base_path.as_ref()); + Ok(self) + } } impl std::fmt::Display for Ingredient { @@ -718,15 +819,32 @@ pub trait IngredientOptions { #[cfg(not(feature = "add_thumbnails"))] None } + + /// Returns an optional folder path + /// + /// If Some, binary data will be stored in files in the given folder + fn base_path(&self) -> Option<&Path> { + None + } } -#[cfg(feature = "file_io")] /// DefaultOptions returns None for Title and Hash and generates thumbnail for supported thumbnails /// /// This can be use with Ingredient::from_file_with_options -pub struct DefaultOptions {} #[cfg(feature = "file_io")] -impl IngredientOptions for DefaultOptions {} +pub struct DefaultOptions { + /// If Some, the ingredient will read/write binary assets using this folder. + /// + /// If None, the assets will be kept in memory. + pub base: Option<std::path::PathBuf>, +} + +#[cfg(feature = "file_io")] +impl IngredientOptions for DefaultOptions { + fn base_path(&self) -> Option<&Path> { + self.base.as_deref() + } +} #[cfg(test)] mod tests { @@ -747,8 +865,10 @@ mod tests { .set_is_parent() .set_metadata(Metadata::new()) .set_thumbnail("format", "thumbnail".as_bytes().to_vec()) + .unwrap() .set_active_manifest("active_manifest") .set_manifest_data("data".as_bytes().to_vec()) + .expect("set_manifest") .add_validation_status(ValidationStatus::new("status_code")); assert_eq!(ingredient.title(), "title2"); assert_eq!(ingredient.format(), "format"); @@ -758,12 +878,17 @@ mod tests { assert_eq!(ingredient.hash(), Some("hash")); assert!(ingredient.is_parent()); assert!(ingredient.metadata().is_some()); + assert_eq!(ingredient.thumbnail().unwrap().0, "format"); + assert_eq!( + *ingredient.thumbnail().unwrap().1, + "thumbnail".as_bytes().to_vec() + ); assert_eq!( - ingredient.thumbnail(), - Some(("format", "thumbnail".as_bytes())) + *ingredient.thumbnail_bytes().unwrap(), + "thumbnail".as_bytes().to_vec() ); assert_eq!(ingredient.active_manifest(), Some("active_manifest")); - assert_eq!(ingredient.manifest_data(), Some("data".as_bytes())); + assert_eq!( ingredient.validation_status().unwrap()[0].code(), "status_code" @@ -786,8 +911,8 @@ mod tests_file_io { const PRERELEASE_JPEG: &str = "prerelease.jpg"; fn stats(ingredient: &Ingredient) -> usize { - let thumb_size = ingredient.thumbnail().map_or(0, |(_, image)| image.len()); - let manifest_data_size = ingredient.manifest_data().map_or(0, |v| v.len()); + let thumb_size = ingredient.thumbnail_bytes().map_or(0, |i| i.len()); + let manifest_data_size = ingredient.manifest_data().map_or(0, |r| r.len()); println!( " {} instance_id: {}, thumb size: {}, manifest_data size: {}", @@ -950,4 +1075,57 @@ mod tests_file_io { println!("ingredient = {ingredient}"); assert_eq!(ingredient.validation_status(), None); } + + #[test] + #[cfg(feature = "file_io")] + fn test_jpg_with_path() { + let ap = fixture_path("CIE-sig-CA.jpg"); + let mut folder = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + folder.push("../target/tmp/ingredient"); + let mut ingredient = Ingredient::from_file_with_folder(ap, folder).expect("from_file"); + println!("ingredient = {ingredient}"); + assert_eq!(ingredient.validation_status(), None); + + // verify we can't set a references that don't exist + assert!(ingredient + .set_thumbnail_ref(ResourceRef::new("Foo", "bar")) + .is_err()); + assert!(ingredient + .set_manifest_data_ref(ResourceRef::new("Foo", "bar")) + .is_err()); + // verify we can set a references that do exist + assert!(ingredient + .set_thumbnail_ref(ResourceRef::new("Foo", "bar")) + .is_err()); + assert!(ingredient + .set_manifest_data_ref(ResourceRef::new("Foo", "bar")) + .is_err()); + } + + #[test] + #[cfg(feature = "file_io")] + fn test_crate_file_based_ingredient() { + let mut folder = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + folder.push("tests/fixtures"); + let mut ingredient = Ingredient::new("title", "format", "instance_id"); + ingredient.resources.set_base_path(folder); + // verify we can't set a references that don't exist + assert!(ingredient + .set_thumbnail_ref(ResourceRef::new("image/jpg", "foo")) + .is_err()); + assert!(ingredient.thumbnail_ref().is_none()); + assert!(ingredient + .set_manifest_data_ref(ResourceRef::new("image/jpg", "foo")) + .is_err()); + assert!(ingredient.manifest_data_ref().is_none()); + // verify we can set a references that do exist + assert!(ingredient + .set_thumbnail_ref(ResourceRef::new("image/jpg", "C.jpg")) + .is_ok()); + assert!(ingredient.thumbnail_ref().is_some()); + assert!(ingredient + .set_manifest_data_ref(ResourceRef::new("c2pa", "cloud_manifest.c2pa")) + .is_ok()); + assert!(ingredient.manifest_data_ref().is_some()); + } } diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs @@ -106,6 +106,9 @@ pub use manifest_store::ManifestStore; mod manifest_store_report; pub use manifest_store_report::ManifestStoreReport; +mod resource_store; +pub use resource_store::{ResourceRef, ResourceStore}; + mod signing_alg; #[cfg(feature = "file_io")] pub use ingredient::{DefaultOptions, IngredientOptions}; @@ -121,12 +124,12 @@ mod signer; pub use signer::Signer; #[cfg(feature = "async_signer")] pub use signer::{AsyncSigner, RemoteSigner}; -/// crate private declarations #[allow(dead_code, clippy::enum_variant_names)] pub(crate) mod asn1; pub(crate) mod assertion; pub(crate) mod asset_handlers; pub(crate) mod asset_io; +/// crate private declarations pub(crate) mod claim; #[cfg(feature = "sign")] diff --git a/sdk/src/manifest.rs b/sdk/src/manifest.rs @@ -11,9 +11,9 @@ // specific language governing permissions and limitations under // each license. -use std::collections::HashMap; #[cfg(feature = "file_io")] use std::path::Path; +use std::{borrow::Cow, collections::HashMap}; use log::{debug, error, warn}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -26,6 +26,7 @@ use crate::{ claim::{Claim, RemoteManifest}, error::{Error, Result}, jumbf, + resource_store::{skip_serializing_resources, ResourceRef, ResourceStore}, salt::DefaultSalt, store::Store, Ingredient, ManifestAssertion, ManifestAssertionKind, @@ -35,15 +36,8 @@ use crate::{asset_io::CAIReadWrite, Signer}; #[cfg(all(feature = "async_signer", feature = "file_io"))] use crate::{AsyncSigner, RemoteSigner}; -/// Function that is used by serde to determine whether or not we should serialize -/// thumbnail data based on the `serialize_thumbnails` flag. -/// (Serialization is disabled by default.) -fn skip_serializing_thumbnails(value: &Option<(String, Vec<u8>)>) -> bool { - !cfg!(feature = "serialize_thumbnails") || value.is_none() -} - /// A Manifest represents all the information in a c2pa manifest -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Default, Deserialize, Serialize)] pub struct Manifest { /// Optional prefix added to the generated Manifest Label /// This is typically Internet domain name for the vendor (i.e. `adobe`) @@ -52,24 +46,28 @@ pub struct Manifest { /// A User Agent formatted string identifying the software/hardware/system produced this claim /// Spaces are not allowed in names, versions can be specified with product/1.0 syntax + #[serde(default = "default_claim_generator")] pub claim_generator: String, /// A human-readable title, generally source filename. title: Option<String>, /// The format of the source file as a MIME type. + #[serde(default = "default_format")] format: String, /// Instance ID from `xmpMM:InstanceID` in XMP metadata. + #[serde(default = "default_instance_id")] instance_id: String, #[serde(skip_serializing_if = "Option::is_none")] claim_generator_hints: Option<HashMap<String, Value>>, - #[serde(skip_serializing_if = "skip_serializing_thumbnails")] - thumbnail: Option<(String, Vec<u8>)>, + #[serde(skip_serializing_if = "Option::is_none")] + thumbnail: Option<ResourceRef>, /// A List of ingredients + #[serde(default = "default_vec")] ingredients: Vec<Ingredient>, /// A List of verified credentials @@ -77,6 +75,7 @@ pub struct Manifest { credentials: Option<Vec<Value>>, /// A list of assertions + #[serde(default = "default_vec")] assertions: Vec<ManifestAssertion>, /// A list of redactions - URIs to a redacted assertions @@ -90,8 +89,30 @@ pub struct Manifest { #[serde(skip_serializing_if = "Option::is_none")] label: Option<String>, - #[serde(skip_deserializing, skip_serializing)] + /// Indicates where a generated manifest goes + #[serde(skip)] remote_manifest: Option<RemoteManifest>, + + /// container for binary assets (like thumbnails) + #[serde(skip_deserializing)] + #[serde(skip_serializing_if = "skip_serializing_resources")] + resources: ResourceStore, +} + +fn default_claim_generator() -> String { + format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) +} + +fn default_instance_id() -> String { + format!("xmp:iid:{}", Uuid::new_v4()) +} + +fn default_format() -> String { + "application/octet-stream".to_owned() +} + +fn default_vec<T>() -> Vec<T> { + Vec::new() } impl Manifest { @@ -99,20 +120,10 @@ impl Manifest { /// requires a claim_generator string (User Agent)) pub fn new<S: Into<String>>(claim_generator: S) -> Self { Self { - vendor: None, - title: None, - format: "application/octet-stream".to_owned(), - instance_id: format!("xmp:iid:{}", Uuid::new_v4()), claim_generator: claim_generator.into(), - claim_generator_hints: None, - thumbnail: None, - ingredients: Vec::new(), - assertions: Vec::new(), - redactions: None, - credentials: None, - signature_info: None, - label: None, - remote_manifest: None, + format: default_format(), + instance_id: default_instance_id(), + ..Default::default() } } /// Returns a User Agent formatted string identifying the software/hardware/system produced this claim @@ -140,19 +151,31 @@ impl Manifest { self.title.as_deref() } - /// Returns a tuple with thumbnail format and image bytes or `None`. - pub fn thumbnail(&self) -> Option<(&str, &[u8])> { + /// Returns thumbnail tuple with Some((content_type, bytes)) or None + /// + pub fn thumbnail(&self) -> Option<(&str, Cow<Vec<u8>>)> { self.thumbnail .as_ref() - .map(|(format, image)| (format.as_str(), image.as_ref())) + .and_then(|t| Some(t.content_type.as_str()).zip(self.resources.get(&t.identifier).ok())) } - /// Returns the [Ingredient]s used by this Manifest + /// Returns a thumbnail ResourceRef or `None`. + pub fn thumbnail_ref(&self) -> Option<&ResourceRef> { + self.thumbnail.as_ref() + } + + /// Returns immutable [Ingredient]s used by this Manifest /// This can include a parent as well as any placed assets pub fn ingredients(&self) -> &[Ingredient] { &self.ingredients } + /// Returns mutable [Ingredient]s used by this Manifest + /// This can include a parent as well as any placed assets + pub fn ingredients_mut(&mut self) -> &mut [Ingredient] { + &mut self.ingredients + } + /// Returns Assertions for this Manifest pub fn assertions(&self) -> &[ManifestAssertion] { &self.assertions @@ -203,10 +226,31 @@ impl Manifest { self } + /// Sets the thumbnail from a ResourceRef. + pub fn set_thumbnail_ref(&mut self, thumbnail: ResourceRef) -> Result<&mut Self> { + // verify the resource referenced exists + if !self.resources.exists(&thumbnail.identifier) { + return Err(Error::NotFound); + }; + self.thumbnail = Some(thumbnail); + Ok(self) + } + /// Sets the thumbnail format and image data. - pub fn set_thumbnail<S: Into<String>>(&mut self, format: S, thumbnail: Vec<u8>) -> &mut Self { - self.thumbnail = Some((format.into(), thumbnail)); - self + pub fn set_thumbnail<S: Into<String>, B: Into<Vec<u8>>>( + &mut self, + format: S, + thumbnail: B, + ) -> Result<&mut Self> { + let base_id = self + .label() + .unwrap_or_else(|| self.instance_id()) + .to_string(); + self.thumbnail = Some( + self.resources + .add_with(&base_id, &format.into(), thumbnail)?, + ); + Ok(self) } /// If set, the embed calls will create a sidecar .c2pa manifest file next to the output file @@ -237,10 +281,15 @@ impl Manifest { self.signature_info.as_ref() } + /// Returns the parent ingredient if it exists + pub fn parent(&self) -> Option<&Ingredient> { + self.ingredients.iter().find(|i| i.is_parent()) + } + /// Sets the parent ingredient, assuring it is first and setting the is_parent flag pub fn set_parent(&mut self, mut ingredient: Ingredient) -> Result<&mut Self> { // there should only be one parent so return an error if we already have one - if self.ingredients.iter().any(|i| i.is_parent()) { + if self.parent().is_some() { error!("parent already added"); return Err(Error::BadParam("Parent parent already added".to_owned())); } @@ -413,8 +462,42 @@ impl Manifest { self.signature_info.to_owned().and_then(|sig| sig.time) } + /// Return an immutable reference to the manifest resources + pub fn resources(&self) -> &ResourceStore { + &self.resources + } + + /// Return a mutable reference to the manifest resources + pub fn resources_mut(&mut self) -> &mut ResourceStore { + &mut self.resources + } + + /// Creates a Manifest from a JSON string formatted as a Manifest + pub fn from_json(json: &str) -> Result<Self> { + serde_json::from_slice(json.as_bytes()).map_err(Error::JsonError) + } + + /// Setting a base path will make the manifest use resource files instead of memory buffers + /// + /// The files will be relative to the given base path + /// Ingredients resources will also be relative to this path + #[cfg(feature = "file_io")] + pub fn with_base_path<P: AsRef<Path>>(&mut self, base_path: P) -> Result<&Self> { + std::fs::create_dir_all(&base_path)?; + self.resources.set_base_path(base_path.as_ref()); + for i in 0..self.ingredients.len() { + // todo: create different subpath for each ingredient? + self.ingredients[i].with_base_path(base_path.as_ref())?; + } + Ok(self) + } + // Generates a Manifest given a store and a manifest label - pub(crate) fn from_store(store: &Store, manifest_label: &str) -> Result<Self> { + pub(crate) fn from_store( + store: &Store, + manifest_label: &str, + #[cfg(feature = "file_io")] resource_path: Option<&Path>, + ) -> Result<Self> { let claim = store .get_claim(manifest_label) .ok_or_else(|| Error::ClaimMissing { @@ -425,6 +508,11 @@ impl Manifest { let claim_generator = claim.claim_generator().to_owned(); let mut manifest = Manifest::new(claim_generator); + #[cfg(feature = "file_io")] + if let Some(base_path) = resource_path { + manifest.with_base_path(base_path)?; + } + manifest.set_label(claim.label()); manifest.claim_generator_hints = claim.get_claim_generator_hint_map().cloned(); @@ -467,12 +555,17 @@ impl Manifest { match base_label.as_ref() { labels::INGREDIENT => { let assertion_uri = jumbf::labels::to_assertion_uri(claim.label(), &label); - let ingredient = Ingredient::from_ingredient_uri(store, &assertion_uri)?; + let ingredient = Ingredient::from_ingredient_uri( + store, + &assertion_uri, + #[cfg(feature = "file_io")] + resource_path, + )?; manifest.add_ingredient(ingredient); } label if label.starts_with(labels::CLAIM_THUMBNAIL) => { let thumbnail = Thumbnail::from_assertion(assertion)?; - manifest.set_thumbnail(thumbnail.content_type, thumbnail.data); + manifest.set_thumbnail(thumbnail.content_type, thumbnail.data)?; } _ => { // inject assertions for all other assertions @@ -499,8 +592,6 @@ impl Manifest { } } - // manifest.set_asset(asset); - let issuer = claim.signing_issuer(); let signing_time = claim .signing_time() @@ -522,7 +613,7 @@ impl Manifest { /// this method can be used to ensure that data is correct /// it will extract filename,format and xmp info and generate a thumbnail #[cfg(feature = "file_io")] - pub fn set_asset_from_path<P: AsRef<Path>>(&mut self, path: P) { + pub fn set_asset_from_path<P: AsRef<Path>>(&mut self, path: P) -> Result<()> { // Gather the information we need from the target path let ingredient = Ingredient::from_file_info(path.as_ref()); @@ -538,9 +629,10 @@ impl Manifest { if self.thumbnail().is_none() { #[cfg(feature = "add_thumbnails")] if let Ok((format, image)) = crate::utils::thumbnail::make_thumbnail(path.as_ref()) { - self.set_thumbnail(format, image); + self.set_thumbnail(format, image)?; } } + Ok(()) } // Convert a Manifest into a Claim @@ -572,10 +664,10 @@ impl Manifest { } claim.format = self.format().to_owned(); claim.instance_id = self.instance_id().to_owned(); - if let Some((format, image)) = self.thumbnail() { + if let Some((content_type, data)) = self.thumbnail() { claim.add_assertion(&Thumbnail::new( - &labels::add_thumbnail_format(labels::CLAIM_THUMBNAIL, format), - image.to_vec(), + &labels::add_thumbnail_format(labels::CLAIM_THUMBNAIL, content_type), + data.to_vec(), ))?; } @@ -592,10 +684,8 @@ impl Manifest { let mut ingredient_map = HashMap::new(); // add all ingredients to the claim for ingredient in &self.ingredients { - ingredient_map.insert( - ingredient.instance_id(), - ingredient.add_to_claim(&mut claim, self.redactions.clone())?, - ); + let uri = ingredient.add_to_claim(&mut claim, self.redactions.clone())?; + ingredient_map.insert(ingredient.instance_id(), uri); } let salt = DefaultSalt::default(); @@ -713,7 +803,7 @@ impl Manifest { copied = true; } // first add the information about the target file - self.set_asset_from_path(dest_path.as_ref()); + self.set_asset_from_path(dest_path.as_ref())?; if copied { Ok(dest_path) @@ -804,7 +894,7 @@ impl Manifest { if let Ok((format, image)) = crate::utils::thumbnail::make_thumbnail_from_stream(format, stream) { - self.set_thumbnail(format, image); + self.set_thumbnail(format, image)?; } } @@ -888,6 +978,7 @@ pub(crate) mod tests { use crate::{ assertions::labels::ACTIONS, error::Error, + resource_store::ResourceRef, status_tracker::{DetailedStatusTracker, StatusTracker}, store::Store, utils::test::{fixture_path, temp_dir_path, temp_fixture_path, TEST_SMALL_JPEG}, @@ -1035,8 +1126,13 @@ pub(crate) mod tests { manifest.add_assertion(&cbor).expect("add_assertion"); let store = manifest.to_store().expect("to_store"); - let _manifest2 = - Manifest::from_store(&store, &store.provenance_label().unwrap()).expect("from_store"); + let _manifest2 = Manifest::from_store( + &store, + &store.provenance_label().unwrap(), + #[cfg(feature = "file_io")] + None, + ) + .expect("from_store"); println!("{store}"); println!("{_manifest2:?}"); let cbor2: UserCbor = manifest.find_assertion(LABEL).expect("get_assertion"); @@ -1167,8 +1263,16 @@ pub(crate) mod tests { let store = manifest.to_store().expect("to_store"); println!("{store}"); let active_label = store.provenance_label().unwrap(); - let manifest2 = Manifest::from_store(&store, &active_label).expect("from_store"); + + let manifest2 = Manifest::from_store( + &store, + &active_label, + #[cfg(feature = "file_io")] + None, + ) + .expect("from_store"); println!("{manifest2}"); + // now check to see if we have three separate assertions with different instances let action2: Result<Actions> = manifest2.find_assertion_with_instance(Actions::LABEL, 2); assert!(action2.is_ok()); @@ -1316,8 +1420,7 @@ pub(crate) mod tests { ); #[cfg(feature = "add_thumbnails")] assert!(manifest_store.get_active().unwrap().thumbnail().is_some()); - - println!("{manifest_store}"); + //println!("{manifest_store}");main } #[cfg(feature = "file_io")] @@ -1386,12 +1489,139 @@ pub(crate) mod tests { let signer = temp_signer(); let mut manifest = test_manifest(); - manifest.set_thumbnail("image/jpeg", vec![1, 2, 3]); + let thumb_data = vec![1, 2, 3]; + manifest + .set_thumbnail("image/jpeg", thumb_data.clone()) + .expect("set_thumbnail"); manifest.embed(&output, &output, &signer).expect("embed"); let manifest_store = crate::ManifestStore::from_file(&output).expect("from_file"); let active_manifest = manifest_store.get_active().unwrap(); - let (format, thumb) = active_manifest.thumbnail().unwrap(); - assert_eq!(format, "image/jpeg"); - assert_eq!(thumb, vec![1, 2, 3]); + let (content_type, image) = active_manifest.thumbnail().unwrap(); + assert_eq!(content_type, "image/jpeg"); + assert_eq!(image.into_owned(), thumb_data); + } + + #[cfg(feature = "file_io")] + const MANIFEST_JSON: &str = r#"{ + "claim_generator": "test", + "format" : "image/jpeg", + "thumbnail": { + "content_type": "image/jpeg", + "identifier": "IMG_0003.jpg" + }, + "ingredients": [{ + "title": "A.jpg", + "format": "image/jpeg", + "document_id": "xmp.did:813ee422-9736-4cdc-9be6-4e35ed8e41cb", + "is_parent": true, + "thumbnail": { + "content_type": "image/png", + "identifier": "exp-test1.png" + } + }] + }"#; + + #[test] + #[cfg(feature = "sign")] + /// tests and illustrates how to add assets to a non-file based manifest + fn from_json_with_memory() { + let mut manifest = Manifest::from_json(MANIFEST_JSON).unwrap(); + // add binary resources to manifest and ingredients giving matching the identifiers given in JSON + manifest + .resources_mut() + .add("IMG_0003.jpg", *b"my value") + .expect("add resource"); + manifest.ingredients_mut()[0] + .resources_mut() + .add("exp-test1.png", *b"my value") + .expect("add_resource"); + + println!("{manifest}"); + + let image = include_bytes!("../tests/fixtures/earth_apollo17.jpg"); + // convert buffer to cursor with Read/Write/Seek capability + let mut stream = std::io::Cursor::new(image.to_vec()); + + let signer = temp_signer(); + // Embed a manifest using the signer. + manifest + .embed_stream("jpeg", &mut stream, &signer) + .expect("embed_stream"); + + // get the updated image + let image = stream.into_inner(); + + let manifest_store = + crate::ManifestStore::from_bytes("jpeg", &image, true).expect("from_bytes"); + let m = manifest_store.get_active().unwrap(); + + assert!(m.thumbnail().is_some()); + let (content_type, image) = m.thumbnail().unwrap(); + assert_eq!(content_type, "image/jpeg"); + assert_eq!(image.to_vec(), b"my value"); + // println!("{manifest_store}"); + } + + #[test] + #[cfg(feature = "file_io")] + fn from_json_with_files() { + let mut manifest = Manifest::from_json(MANIFEST_JSON).unwrap(); + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures"); // the path we want to read files from + manifest.with_base_path(path).expect("with_files"); + println!("{manifest}"); + // convert the manifest to a store + let store = manifest.to_store().expect("to store"); + let mut resource_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + resource_path.push("../target/tmp/manifest"); + let m2 = Manifest::from_store( + &store, + &store.provenance_label().unwrap(), + Some(&resource_path), + ) + .expect("from store"); + assert!(m2.thumbnail().is_some()); + assert!(m2.ingredients()[0].thumbnail().is_some()); + } + + #[cfg(feature = "file_io")] + #[test] + fn test_embed_from_json() { + let mut fixtures = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + fixtures.push("tests/fixtures"); // the path we want to read files from + + let temp_dir = tempdir().expect("temp dir"); + let output = temp_fixture_path(&temp_dir, TEST_SMALL_JPEG); + + let signer = temp_signer(); + + let mut manifest = Manifest::from_json(MANIFEST_JSON).expect("from_json"); + manifest.with_base_path(fixtures).expect("with_base"); + manifest.embed(&output, &output, &signer).expect("embed"); + + let manifest_store = crate::ManifestStore::from_file(&output).expect("from_file"); + println!("{manifest_store}"); + let active_manifest = manifest_store.get_active().unwrap(); + let (content_type, _) = active_manifest.thumbnail().unwrap(); + assert_eq!(content_type, "image/jpeg"); + } + + #[test] + #[cfg(feature = "file_io")] + fn test_crate_file_based_ingredient() { + let mut folder = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + folder.push("tests/fixtures"); + let mut manifest = Manifest::new("claim_generator"); + manifest.resources.set_base_path(folder); + // verify we can't set a references that don't exist + assert!(manifest + .set_thumbnail_ref(ResourceRef::new("image/jpg", "foo")) + .is_err()); + assert!(manifest.thumbnail_ref().is_none()); + // verify we can set a references that do exist + assert!(manifest + .set_thumbnail_ref(ResourceRef::new("image/jpg", "C.jpg")) + .is_ok()); + assert!(manifest.thumbnail_ref().is_some()); } } diff --git a/sdk/src/manifest_store.rs b/sdk/src/manifest_store.rs @@ -77,11 +77,35 @@ impl ManifestStore { self.validation_status.as_deref() } - /// creates a ManifestStore from a Store + /// creates a ManifestStore from a Store with validation pub(crate) fn from_store( store: &Store, validation_log: &mut impl StatusTracker, ) -> ManifestStore { + Self::from_store_impl( + store, + validation_log, + #[cfg(feature = "file_io")] + None, + ) + } + + /// creates a ManifestStore from a Store writing resources to resource_path + #[cfg(feature = "file_io")] + pub fn from_store_with_resources( + store: &Store, + validation_log: &mut impl StatusTracker, + resource_path: &Path, + ) -> ManifestStore { + Self::from_store_impl(store, validation_log, Some(resource_path)) + } + + // internal implementation of from_store + fn from_store_impl( + store: &Store, + validation_log: &mut impl StatusTracker, + #[cfg(feature = "file_io")] resource_path: Option<&Path>, + ) -> ManifestStore { let mut statuses = status_for_store(store, validation_log); let mut manifest_store = ManifestStore::new(); @@ -89,7 +113,11 @@ impl ManifestStore { for claim in store.claims() { let manifest_label = claim.label(); - match Manifest::from_store(store, manifest_label) { + #[cfg(feature = "file_io")] + let result = Manifest::from_store(store, manifest_label, resource_path); + #[cfg(not(feature = "file_io"))] + let result = Manifest::from_store(store, manifest_label); + match result { Ok(manifest) => { manifest_store .manifests @@ -112,7 +140,12 @@ impl ManifestStore { pub fn from_manifest(manifest: &Manifest) -> Result<Self> { use crate::status_tracker::OneShotStatusTracker; let store = manifest.to_store()?; - Ok(Self::from_store(&store, &mut OneShotStatusTracker::new())) + Ok(Self::from_store_impl( + &store, + &mut OneShotStatusTracker::new(), + #[cfg(feature = "file_io")] + manifest.resources().base_path(), + )) } /// generate a Store from a format string and bytes @@ -143,6 +176,33 @@ impl ManifestStore { Ok(Self::from_store(&store, &mut validation_log)) } + #[cfg(feature = "file_io")] + /// Loads a ManifestStore from a file adding resources to a folder + /// Example: + /// + /// ``` + /// # use c2pa::Result; + /// use c2pa::ManifestStore; + /// # fn main() -> Result<()> { + /// let manifest_store = ManifestStore::from_file_with_resources("tests/fixtures/C.jpg","../target/tmp/manifest_store")?; + /// println!("{}", manifest_store); + /// # Ok(()) + /// # } + /// ``` + pub fn from_file_with_resources<P: AsRef<Path>>( + path: P, + resource_path: P, + ) -> Result<ManifestStore> { + let mut validation_log = DetailedStatusTracker::new(); + + let store = Store::load_from_asset(path.as_ref(), true, &mut validation_log)?; + Ok(Self::from_store_with_resources( + &store, + &mut validation_log, + resource_path.as_ref(), + )) + } + /// Loads a ManifestStore from a file pub async fn from_bytes_async( format: &str, @@ -346,4 +406,24 @@ mod tests { assert!(manifest_store.validation_status().is_none()); println!("{manifest_store}"); } + + #[test] + #[cfg(feature = "file_io")] + fn manifest_report_from_file_with_resources() { + let manifest_store = ManifestStore::from_file_with_resources( + "tests/fixtures/CIE-sig-CA.jpg", + "../target/tmp/ms", + ) + .expect("from_store_with_resources"); + println!("{manifest_store}"); + + assert!(manifest_store.active_label().is_some()); + assert!(manifest_store.get_active().is_some()); + assert!(!manifest_store.manifests().is_empty()); + assert!(manifest_store.validation_status().is_none()); + let manifest = manifest_store.get_active().unwrap(); + assert!(!manifest.ingredients().is_empty()); + assert_eq!(manifest.issuer().unwrap(), "C2PA Test Signing Cert"); + assert!(manifest.time().is_some()); + } } diff --git a/sdk/src/manifest_store_report.rs b/sdk/src/manifest_store_report.rs @@ -292,7 +292,11 @@ impl ManifestReport { Ok(Self { claim: serde_json::to_value(claim)?, // todo: this will lose tagging info assertion_store, - credential_store: (!credential_store.is_empty()).then(|| credential_store), + credential_store: if !credential_store.is_empty() { + Some(credential_store) + } else { + None + }, signature, }) } diff --git a/sdk/src/resource_store.rs b/sdk/src/resource_store.rs @@ -0,0 +1,242 @@ +// Copyright 2023 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +#[cfg(feature = "file_io")] +use std::path::{Path, PathBuf}; +use std::{borrow::Cow, collections::HashMap}; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// Function that is used by serde to determine whether or not we should serialize +/// resources based on the `serialize_resources` flag. +/// (Serialization is disabled by default.) +pub(crate) fn skip_serializing_resources(_: &ResourceStore) -> bool { + !cfg!(feature = "serialize_thumbnails") || cfg!(test) +} + +/// A reference to a resource to be used in JSON serialization +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct ResourceRef { + pub content_type: String, + pub identifier: String, +} + +impl ResourceRef { + pub fn new<S: Into<String>, I: Into<String>>(content_type: S, identifier: I) -> Self { + Self { + content_type: content_type.into(), + identifier: identifier.into(), + } + } +} + +/// Resource store to contain binary objects referenced from JSON serializable structures +#[derive(Debug, Serialize)] +pub struct ResourceStore { + resources: HashMap<String, Vec<u8>>, + #[cfg(feature = "file_io")] + base_path: Option<PathBuf>, +} + +impl ResourceStore { + pub fn new() -> Self { + ResourceStore { + resources: HashMap::new(), + #[cfg(feature = "file_io")] + base_path: None, + } + } + + #[cfg(feature = "file_io")] + pub fn base_path(&self) -> Option<&Path> { + self.base_path.as_deref() + } + + #[cfg(feature = "file_io")] + pub fn set_base_path<P: Into<PathBuf>>(&mut self, base_path: P) { + self.base_path = Some(base_path.into()); + } + + /// generate a unique id for a given content type (adds a file extension) + pub fn id_from(&self, key: &str, format: &str) -> String { + let ext = match format { + "jpg" | "jpeg" | "image/jpeg" => ".jpg", + "png" | "image/png" => ".png", + "c2pa" | "application/x-c2pa-manifest-store" => ".cp2a", + _ => "", + }; + // clean string for possible filesystem use + let mut id = key.replace(['/', ':'], "-") + ext; + + // ensure it is unique in this store + let count = 1; + while self.exists(&id) { + id = format!("{id}-{count}{ext}"); + } + id + } + + /// Adds a resource, generating a resource ref from a key and format. + /// + /// The generated identifier may be different from the key + pub fn add_with<R>(&mut self, key: &str, format: &str, value: R) -> crate::Result<ResourceRef> + where + R: Into<Vec<u8>>, + { + let id = self.id_from(key, format); + self.add(id.clone(), value)?; + Ok(ResourceRef::new(format, id)) + } + + /// Adds a resource, using a given id value. + pub fn add<S, R>(&mut self, id: S, value: R) -> crate::Result<()> + where + S: Into<String>, + R: Into<Vec<u8>>, + { + #[cfg(feature = "file_io")] + if let Some(base) = self.base_path.as_ref() { + let path = base.join(id.into()); + std::fs::write(path, value.into())?; + return Ok(()); + } + self.resources.insert(id.into(), value.into()); + Ok(()) + } + + /// Returns a copy on write reference to the resource if found. + /// + /// returns Error::NotFound if it cannot find a resource matching that id + pub fn get(&self, id: &str) -> Result<Cow<Vec<u8>>> { + #[cfg(feature = "file_io")] + if !self.resources.contains_key(id) { + match self.base_path.as_ref() { + Some(base) => { + // read the file, save in Map and then return a reference + let path = base.join(id); + let value = std::fs::read(path)?; + return Ok(Cow::Owned(value)); + } + None => return Err(Error::NotFound), + } + } + self.resources + .get(id) + .map_or_else(|| Err(Error::NotFound), |v| Ok(Cow::Borrowed(v))) + } + + /// Returns true if the resource has been added or exists as file. + pub fn exists(&self, id: &str) -> bool { + if !self.resources.contains_key(id) { + #[cfg(feature = "file_io")] + match self.base_path.as_ref() { + Some(base) => { + let path = base.join(id); + path.exists() + } + None => false, + } + #[cfg(not(feature = "file_io"))] + false + } else { + true + } + } + + #[cfg(feature = "file_io")] + // return the full path for an id + pub fn path_for_id(&self, id: &str) -> Option<PathBuf> { + self.base_path.as_ref().map(|base| base.join(id)) + } +} + +impl Default for ResourceStore { + fn default() -> Self { + ResourceStore::new() + } +} + +#[cfg(test)] +#[cfg(feature = "sign")] +mod tests { + #![allow(clippy::expect_used)] + #![allow(clippy::unwrap_used)] + use super::*; + use crate::{utils::test::temp_signer, Manifest}; + + #[test] + #[cfg(feature = "sign")] + fn resource_store() { + let mut c = ResourceStore::new(); + let value = b"my value"; + c.add("abc123.jpg", value.to_vec()).expect("add"); + let v = c.get("abc123.jpg").unwrap(); + assert_eq!(v.to_vec(), b"my value"); + c.add("cba321.jpg", value.to_vec()).expect("add"); + assert!(c.exists("cba321.jpg")); + assert!(!c.exists("foo")); + + let json = r#"{ + "claim_generator": "test", + "format" : "image/jpeg", + "instance_id": "12345", + "assertions": [], + "thumbnail": { + "content_type": "image/jpeg", + "identifier": "abc123" + }, + "ingredients": [{ + "title": "A.jpg", + "format": "image/jpeg", + "document_id": "xmp.did:813ee422-9736-4cdc-9be6-4e35ed8e41cb", + "instance_id": "xmp.iid:813ee422-9736-4cdc-9be6-4e35ed8e41cb", + "is_parent": true, + "thumbnail": { + "content_type": "image/jpeg", + "identifier": "cba321" + } + }] + }"#; + + let mut manifest = Manifest::from_json(json).expect("from json"); + manifest + .resources_mut() + .add("abc123", *value) + .expect("add_resource"); + let ingredient = &mut manifest.ingredients_mut()[0]; + ingredient + .resources_mut() + .add("cba321", *value) + .expect("add_resource"); + println!("{manifest}"); + + let image = include_bytes!("../tests/fixtures/earth_apollo17.jpg"); + // convert buffer to cursor with Read/Write/Seek capability + let mut stream = std::io::Cursor::new(image.to_vec()); + + let signer = temp_signer(); + // Embed a manifest using the signer. + manifest + .embed_stream("jpeg", &mut stream, &signer) + .expect("embed_stream"); + + // get the updated image + let image = stream.into_inner(); + + let _manifest_store = + crate::ManifestStore::from_bytes("jpeg", &image, true).expect("from_bytes"); + // println!("{manifest_store}"); + } +}