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 f2af393d8c736e9d0f12dac34c03712ccfe5be90
parent 86e928e0739a5f7d4f2402fdb65b46c12a33239b
Author: Gavin  Peacock <gpeacock@adobe.com>
Date:   Tue, 17 Oct 2023 09:26:52 -0700

(MINOR) Reuse claim thumbnail as ingredient thumbnail if the store is valid (#322)

* Use jumbf uris for ManifestStore identifiers
* New generated file paths with
* Manifest label folders written to base_path
* Added ResourceStore set_label().

* References a claim thumbnail as the ingredient thumbnail instead of duplicating it if there are no validation errors.
* Changes Manifest.fromStore to iterate over assertions in the claim rather than the assertion store.
* Removes resource exists test from Ingredient.set_thumbnail
* Adds optional alg and hash fields to ResourceRef.
* Adds to_absolute_url internal method
* Added more ResourceRef documentation.
* c2pa manifest format is now application/c2pa instead of c2pa
* Add since (version) attribute to deprecated annotation
* (MINOR) This PR needs a minor version bump
* Documentation edits

---------

Co-authored-by: Eric Scouten <scouten@adobe.com>
Diffstat:
Msdk/src/ingredient.rs | 106++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Msdk/src/jumbf/labels.rs | 11+++++++++++
Msdk/src/manifest.rs | 9++++++---
Msdk/src/resource_store.rs | 44+++++++++++++++++++++++++++++++++++---------
4 files changed, 114 insertions(+), 56 deletions(-)

diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs @@ -39,7 +39,7 @@ use crate::{ resource_store::{skip_serializing_resources, ResourceRef, ResourceStore}, status_tracker::{log_item, DetailedStatusTracker, StatusTracker}, store::Store, - utils::xmp_inmemory_utils::XmpInfo, + utils::{base64, xmp_inmemory_utils::XmpInfo}, validation_status::{self, status_for_store, ValidationStatus}, }; @@ -360,10 +360,6 @@ impl Ingredient { /// 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) } @@ -442,7 +438,10 @@ impl Ingredient { /// 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)?); + self.manifest_data = Some( + self.resources + .add_with(&base_id, "application/c2pa", data)?, + ); Ok(self) } @@ -585,15 +584,28 @@ impl Ingredient { if let Some(claim) = store.provenance_claim() { // if the parent claim is valid and has a thumbnail, use it if statuses.is_empty() { - // search claim to find a claim thumbnail assertion without knowing the format - if let Some(claim_assertion) = claim - .claim_assertion_store() + if let Some(hashed_uri) = claim + .assertions() .iter() - .find(|ca| ca.label_raw().starts_with(labels::CLAIM_THUMBNAIL)) + .find(|hashed_uri| hashed_uri.url().contains(labels::CLAIM_THUMBNAIL)) { - let (format, image) = - Self::thumbnail_from_assertion(claim_assertion.assertion()); - self.set_thumbnail(format, image)?; + // We found a valid claim thumbnail so just reference it, we don't need to copy it + let thumb_manifest = manifest_label_from_uri(&hashed_uri.url()) + .unwrap_or_else(|| claim.label().to_string()); + let uri = + jumbf::labels::to_absolute_uri(&thumb_manifest, &hashed_uri.url()); + // Try to determine the format from the assertion label in the URL + let format = hashed_uri + .url() + .rsplit_once('.') + .map(|(_, ext)| format!("image/{}", ext)) + .unwrap_or_else(|| "image/jpeg".to_string()); // default to jpeg?? + let mut thumb = crate::resource_store::ResourceRef::new(format, uri); + // keep track of the alg and hash for reuse + thumb.alg = hashed_uri.alg(); + let hash = base64::encode(&hashed_uri.hash()); + thumb.hash = Some(hash); + self.set_thumbnail_ref(thumb)?; } } self.active_manifest = Some(claim.label().to_string()); @@ -1092,11 +1104,26 @@ impl Ingredient { // if the ingredient defines a thumbnail, add it to the claim // otherwise use the parent claim thumbnail if available if let Some(thumb_ref) = self.thumbnail_ref() { - let data = self.thumbnail_bytes()?; - let hash_url = claim.add_assertion(&Thumbnail::new( - &labels::add_thumbnail_format(labels::INGREDIENT_THUMBNAIL, &thumb_ref.format), - data.into_owned(), - ))?; + let hash_url = match manifest_label_from_uri(&thumb_ref.identifier) { + Some(_) => { + let hash = match thumb_ref.hash.as_ref() { + Some(h) => base64::decode(h) + .map_err(|_e| Error::BadParam("Invalid hash".to_string()))?, + None => return Err(Error::BadParam("hash is missing".to_string())), /* todo: add hash missing error */ + }; + HashedUri::new(thumb_ref.identifier.clone(), thumb_ref.alg.clone(), &hash) + } + None => { + let data = self.thumbnail_bytes()?; + claim.add_assertion(&Thumbnail::new( + &labels::add_thumbnail_format( + labels::INGREDIENT_THUMBNAIL, + &thumb_ref.format, + ), + data.into_owned(), + ))? + } + }; thumbnail = Some(hash_url); } @@ -1567,7 +1594,7 @@ mod tests_file_io { println!("ingredient = {ingredient}"); assert_eq!(&ingredient.title, MANIFEST_JPEG); assert_eq!(ingredient.format(), "image/jpeg"); - assert!(ingredient.thumbnail().is_some()); // we don't generate this thumbnail + assert!(ingredient.thumbnail_ref().is_some()); // we don't generate this thumbnail assert!(ingredient.manifest_data().is_some()); assert!(ingredient.metadata().is_none()); } @@ -1613,16 +1640,15 @@ mod tests_file_io { } } - let ap = fixture_path(MANIFEST_JPEG); + let ap = fixture_path(NO_MANIFEST_JPEG); let ingredient = Ingredient::from_file_with_options(ap, &MyOptions {}).expect("from_file"); stats(&ingredient); - println!("ingredient = {ingredient}"); assert_eq!(ingredient.title(), "MyTitle"); assert_eq!(ingredient.format(), "image/jpeg"); - assert!(ingredient.hash().is_some()); - assert!(ingredient.thumbnail().is_some()); // always generated - assert!(ingredient.manifest_data().is_some()); + assert_eq!(ingredient.hash(), Some("1234568abcdef")); + assert_eq!(ingredient.thumbnail_ref().unwrap().format, "image/foo"); // always generated + assert!(ingredient.manifest_data().is_none()); assert!(ingredient.metadata().is_none()); } @@ -1709,24 +1735,19 @@ mod tests_file_io { let ap = fixture_path("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"); + let 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 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 + // verify ingredient thumbnail is an absolute url reference to a claim thumbnail assert!(ingredient - .set_thumbnail_ref(ResourceRef::new("Foo", "bar")) - .is_err()); - assert!(ingredient - .set_manifest_data_ref(ResourceRef::new("Foo", "bar")) - .is_err()); + .thumbnail_ref() + .unwrap() + .identifier + .contains(labels::JPEG_CLAIM_THUMBNAIL)); + + // verify manifest_data exists + assert!(ingredient.manifest_data_ref().is_some()); } #[test] @@ -1736,22 +1757,19 @@ mod tests_file_io { 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 + // verify we can set a reference 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")) + .set_manifest_data_ref(ResourceRef::new("application/c2pa", "cloud_manifest.c2pa")) .is_ok()); assert!(ingredient.manifest_data_ref().is_some()); } diff --git a/sdk/src/jumbf/labels.rs b/sdk/src/jumbf/labels.rs @@ -122,6 +122,17 @@ pub(crate) fn to_normalized_uri(uri: &str) -> String { } } +// Converts a possibly relative JUMBF URI to an absolute URI to the manifest store. +pub(crate) fn to_absolute_uri(manifest_label: &str, uri: &str) -> String { + let raw_uri = to_normalized_uri(uri); + let parts: Vec<&str> = raw_uri.split('/').collect(); + if parts.len() > 2 && parts[1] == MANIFEST_STORE { + uri.to_string() + } else { + format!("{}/{}", to_manifest_uri(manifest_label), raw_uri) + } +} + // Converts an absolute JUMBF URI to a URI relative to the manifest store. pub(crate) fn to_relative_uri(uri: &str) -> String { let raw_uri = to_normalized_uri(uri); diff --git a/sdk/src/manifest.rs b/sdk/src/manifest.rs @@ -554,7 +554,10 @@ impl Manifest { manifest.set_format(claim.format()); manifest.set_instance_id(claim.instance_id()); - for claim_assertion in claim.claim_assertion_store().iter() { + for assertion in claim.assertions() { + let claim_assertion = store.get_claim_assertion_from_uri( + &jumbf::labels::to_absolute_uri(claim.label(), &assertion.url()), + )?; let assertion = claim_assertion.assertion(); let label = claim_assertion.label(); let base_label = assertion.label(); @@ -1659,8 +1662,8 @@ pub(crate) mod tests { .expect("embed"); //let manifest_store = crate::ManifestStore::from_file(&sidecar).expect("from_file"); - let manifest_store = - crate::ManifestStore::from_bytes("c2pa", &c2pa_data, true).expect("from_bytes"); + let manifest_store = crate::ManifestStore::from_bytes("application/c2pa", &c2pa_data, true) + .expect("from_bytes"); assert_eq!(manifest_store.active_label(), Some("MyLabel")); assert_eq!( manifest_store.get_active().unwrap().title().unwrap(), diff --git a/sdk/src/resource_store.rs b/sdk/src/resource_store.rs @@ -84,15 +84,31 @@ impl From<HashedUri> for UriOrResource { } } -/// A reference to a resource to be used in JSON serialization #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] - +/// A reference to a resource to be used in JSON serialization. pub struct ResourceRef { + /// The mime type of the referenced resource. pub format: String, + + /// A URI that identifies the resource as referenced from the manifest. + /// + /// This may be a JUMBF URI, a file path, a URL or any other string. + /// Relative JUMBF URIs will be resolved with the manifest label. + /// Relative file paths will be resolved with the base path if provided. pub identifier: String, #[serde(skip_serializing_if = "Option::is_none")] + + /// More detailed data types as defined in the C2PA spec. pub data_types: Option<Vec<AssetType>>, + #[serde(skip_serializing_if = "Option::is_none")] + + /// The algorithm used to hash the resource (if applicable). + pub alg: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + + /// The hash of the resource (if applicable). + pub hash: Option<String>, } impl ResourceRef { @@ -101,6 +117,8 @@ impl ResourceRef { format: format.into(), identifier: identifier.into(), data_types: None, + alg: None, + hash: None, } } } @@ -118,6 +136,7 @@ pub struct ResourceStore { } impl ResourceStore { + /// Create a new resource reference. pub fn new() -> Self { ResourceStore { resources: HashMap::new(), @@ -127,33 +146,39 @@ impl ResourceStore { } } + /// Set a manifest label for this store used to resolve relative JUMBF URIs. pub fn set_label<S: Into<String>>(&mut self, label: S) -> &Self { self.label = Some(label.into()); self } #[cfg(feature = "file_io")] + // Returns the base path for relative file paths if it is set. pub fn base_path(&self) -> Option<&Path> { self.base_path.as_deref() } #[cfg(feature = "file_io")] + /// Sets a base path for relative file paths. + /// + /// Identifiers will be interpreted as file paths and resources will be written to files if this is set. pub fn set_base_path<P: Into<PathBuf>>(&mut self, base_path: P) { self.base_path = Some(base_path.into()); } #[cfg(feature = "file_io")] + /// Returns and removes the base path. pub fn take_base_path(&mut self) -> Option<PathBuf> { self.base_path.take() } - /// generate a unique id for a given content type (adds a file extension) + /// Generates 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", //make "svg" | "image/svg+xml" => ".svg", - "c2pa" | "application/x-c2pa-manifest-store" => ".c2pa", + "c2pa" | "application/x-c2pa-manifest-store" | "application/c2pa" => ".c2pa", _ => "", }; // clean string for possible filesystem use @@ -169,9 +194,9 @@ impl ResourceStore { id } - /// Adds a resource, generating a resource ref from a key and format. + /// Adds a resource, generating a [`ResourceRef`] from a key and format. /// - /// The generated identifier may be different from the key + /// 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>>, @@ -235,13 +260,14 @@ impl ResourceStore { Ok(self) } + /// Returns a [`HashMap`] of internal resources. pub fn resources(&self) -> &HashMap<String, Vec<u8>> { &self.resources } /// Returns a copy on write reference to the resource if found. /// - /// returns Error::NotFound if it cannot find a resource matching that id + /// Returns [`Error::ResourceNotFound`] 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) { @@ -264,7 +290,7 @@ impl ResourceStore { ) } - /// Returns true if the resource has been added or exists as file. + /// 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")] @@ -283,7 +309,7 @@ impl ResourceStore { } #[cfg(feature = "file_io")] - // return the full path for an id + // Returns 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)) }