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 4ee1a714a0725e5b8b2c16d03281145cc8ee8937
parent c33c9c7df03c4df3e2b847f1124fc73243595252
Author: mauricefisher64 <92736594+mauricefisher64@users.noreply.github.com>
Date:   Tue, 30 May 2023 14:15:31 -0400

(MINOR) Support for Ingredients V2 and Actions V2 (#258)

* Databox initial work

* Support ingredient.v2

* Adds Ingredient getter and setters.
set_relationship
set_data_ref
set_description
set_informational_uri
relationship
data_ref
description
informational_uri
+ extended unit tests

* DataBox support

* Unit tests

* Fixed data box bug

* fmt fix

* Integrate with higher level Ingredients

* Push data_box fetching

* Actions v2 and claim_generator_info WIP

* generate v2 actions when needed

* Create and read actions v2 at the lower level
will detect actions v2 vs v1 for creation
action.when and manifest.dataTime converted to DateT

* adds UriOrResource logic for HashedUris in public SDK
icons now supported in ClaimGeneratorInfo
adds MissingDataBox error

* remove dead code

* convert icons with UriOrResourceRef

* merge fixes for schema

* convert icons in templates

* use correct svg mime type in tests

* clippy fixes

* Set base_path for claim_generator_info

* Should not return on this error.

* made ActionTemplate, Actor, and DataSource non_exhaustive

---------

Co-authored-by: Gavin Peacock <gpeacock@adobe.com>
Diffstat:
Msdk/src/assertion.rs | 9+++++++--
Msdk/src/assertions/actions.rs | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msdk/src/assertions/ingredient.rs | 51++++++++++++++++++++++++++++++++++++++++++---------
Msdk/src/assertions/metadata.rs | 29++++++++++++++++++++++++++---
Msdk/src/assertions/mod.rs | 6++++--
Msdk/src/assertions/user_cbor.rs | 4++++
Msdk/src/asset_handlers/bmff_io.rs | 1+
Msdk/src/claim.rs | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asdk/src/claim_generator_info.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msdk/src/cose_validator.rs | 2--
Msdk/src/error.rs | 7+++++++
Msdk/src/hashed_uri.rs | 2+-
Msdk/src/ingredient.rs | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msdk/src/jumbf/boxes.rs | 55++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msdk/src/jumbf/labels.rs | 22++++++++++++++++++++++
Msdk/src/lib.rs | 4++++
Msdk/src/manifest.rs | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msdk/src/resource_store.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msdk/src/store.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msdk/src/utils/cbor_types.rs | 13++++++++++++-
Msdk/src/utils/test.rs | 5+++++
Asdk/tests/fixtures/manifest.json | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asdk/tests/fixtures/prompt.txt | 2++
Msdk/tests/integration.rs | 41+++++++++++++++++++++++++++++++++++++++++
24 files changed, 1529 insertions(+), 93 deletions(-)

diff --git a/sdk/src/assertion.rs b/sdk/src/assertion.rs @@ -115,6 +115,11 @@ where Self::LABEL } + /// Returns a version for this assertion. + fn version(&self) -> Option<usize> { + Self::VERSION + } + /// Returns an Assertion upon success or Error otherwise. fn to_assertion(&self) -> Result<Assertion>; @@ -127,7 +132,7 @@ pub trait AssertionCbor: Serialize + DeserializeOwned + AssertionBase { fn to_cbor_assertion(&self) -> Result<Assertion> { let data = AssertionData::Cbor(serde_cbor::to_vec(self).map_err(|_err| Error::AssertionEncoding)?); - Ok(Assertion::new(self.label(), Self::VERSION, data)) + Ok(Assertion::new(self.label(), self.version(), data)) } fn from_cbor_assertion(assertion: &Assertion) -> Result<Self> { @@ -154,7 +159,7 @@ pub trait AssertionJson: Serialize + DeserializeOwned + AssertionBase { let data = AssertionData::Json( serde_json::to_string(self).map_err(|_err| Error::AssertionEncoding)?, ); - Ok(Assertion::new(self.label(), Self::VERSION, data).set_content_type("application/json")) + Ok(Assertion::new(self.label(), self.version(), data).set_content_type("application/json")) } fn from_json_assertion(assertion: &Assertion) -> Result<Self> { diff --git a/sdk/src/assertions/actions.rs b/sdk/src/assertions/actions.rs @@ -20,9 +20,13 @@ use crate::{ assertion::{Assertion, AssertionBase, AssertionCbor}, assertions::{labels, Actor, Metadata}, error::Result, - Error, + resource_store::UriOrResource, + utils::cbor_types::DateT, + ClaimGeneratorInfo, Error, }; +const ASSERTION_CREATION_VERSION: usize = 2; + /// Specification defined C2PA actions pub mod c2pa_action { /// Changes to tone, saturation, etc. @@ -59,6 +63,26 @@ pub mod c2pa_action { pub const UNKNOWN: &str = "c2pa.unknown"; } +/// We use this to allow SourceAgent to be either a string or a ClaimGeneratorInfo +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum SoftwareAgent { + String(String), + ClaimGeneratorInfo(ClaimGeneratorInfo), +} + +impl From<&str> for SoftwareAgent { + fn from(s: &str) -> Self { + Self::String(s.to_owned()) + } +} + +impl From<ClaimGeneratorInfo> for SoftwareAgent { + fn from(c: ClaimGeneratorInfo) -> Self { + Self::ClaimGeneratorInfo(c) + } +} + /// Defines a single action taken on an asset. /// /// An [`Action`] describes what took place on the asset, when it took place, @@ -66,18 +90,18 @@ pub mod c2pa_action { /// the action. /// /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_actions>. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq, Eq)] pub struct Action { /// The label associated with this action. See ([`c2pa_action`]). action: String, /// Timestamp of when the action occurred. #[serde(skip_serializing_if = "Option::is_none")] - when: Option<String>, + when: Option<DateT>, /// The software agent that performed the action. #[serde(rename = "softwareAgent", skip_serializing_if = "Option::is_none")] - software_agent: Option<String>, + software_agent: Option<SoftwareAgent>, /// A semicolon-delimited list of the parts of the resource that were changed since the previous event history. /// @@ -102,6 +126,14 @@ pub struct Action { /// One of the defined URI values at `<https://cv.iptc.org/newscodes/digitalsourcetype/>` #[serde(rename = "digitalSourceType", skip_serializing_if = "Option::is_none")] source_type: Option<String>, + + /// List of related actions. + #[serde(skip_serializing_if = "Option::is_none")] + related: Option<Vec<Action>>, + + // The reason why this action was performed, required when the action is `c2pa.redacted` + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<String>, } impl Action { @@ -112,16 +144,17 @@ impl Action { pub fn new(label: &str) -> Self { Self { action: label.to_owned(), - when: None, - software_agent: None, - changed: None, - instance_id: None, - parameters: None, - actors: None, - source_type: None, + ..Default::default() } } + fn is_v2(&self) -> bool { + matches!( + self.software_agent, + Some(SoftwareAgent::ClaimGeneratorInfo(_)) + ) + } + /// Returns the label for this action. /// /// This label is often one of the labels defined in [`c2pa_action`], @@ -138,8 +171,13 @@ impl Action { } /// Returns the software agent that performed the action. - pub fn software_agent(&self) -> Option<&str> { - self.software_agent.as_deref() + pub fn software_agent(&self) -> Option<&SoftwareAgent> { + self.software_agent.as_ref() + } + + /// Returns a mutable software agent that performed the action. + pub fn software_agent_mut(&mut self) -> Option<&mut SoftwareAgent> { + self.software_agent.as_mut() } /// Returns the value of the `xmpMM:InstanceID` property for the modified @@ -173,16 +211,32 @@ impl Action { self.source_type.as_deref() } + /// Returns the list of related actions. + /// + /// This is only present in C2PA v2. + /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_related>. + pub fn related(&self) -> Option<&[Action]> { + self.related.as_deref() + } + + /// Returns the reason why this action was performed. + /// + /// This is only present in C2PA v2. + /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_reason>. + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } + /// Sets the timestamp for when the action occurred. /// /// This timestamp must be in ISO-8601 date. pub fn set_when<S: Into<String>>(mut self, when: S) -> Self { - self.when = Some(when.into()); + self.when = Some(DateT(when.into())); self } /// Sets the software agent that performed the action. - pub fn set_software_agent<S: Into<String>>(mut self, software_agent: S) -> Self { + pub fn set_software_agent<S: Into<SoftwareAgent>>(mut self, software_agent: S) -> Self { self.software_agent = Some(software_agent.into()); self } @@ -235,6 +289,58 @@ impl Action { self.source_type = Some(uri.into()); self } + + /// Sets the list of related actions. + /// + /// This is only present in C2PA v2. + /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_related>. + pub fn set_related(mut self, related: Option<&Vec<Action>>) -> Self { + self.related = related.cloned(); + self + } + + /// Sets the reason why this action was performed. + /// + /// This is only present in C2PA v2. + /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_reason>. + pub fn set_reason<S: Into<String>>(mut self, reason: S) -> Self { + self.reason = Some(reason.into()); + self + } +} + +#[derive(Deserialize, Serialize, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct ActionTemplate { + /// The label associated with this action. See ([`c2pa_action`]). + pub action: String, + + /// The software agent that performed the action. + #[serde(rename = "softwareAgent", skip_serializing_if = "Option::is_none")] + pub software_agent: Option<SoftwareAgent>, + + /// One of the defined URI values at `<https://cv.iptc.org/newscodes/digitalsourcetype/>` + #[serde(rename = "digitalSourceType", skip_serializing_if = "Option::is_none")] + pub source_type: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option<UriOrResource>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option<HashMap<String, Value>>, +} + +impl ActionTemplate { + /// Creates a new ActionTemplate. + pub fn new<S: Into<String>>(action: S) -> Self { + Self { + action: action.into(), + ..Default::default() + } + } } /// An `Actions` assertion provides information on edits and other @@ -246,10 +352,15 @@ impl Action { /// /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_actions>. #[derive(Deserialize, Serialize, Debug, PartialEq, Eq)] +#[non_exhaustive] pub struct Actions { /// A list of [`Action`]s. pub actions: Vec<Action>, + /// list of templates for the [`Action`]s + #[serde(skip_serializing_if = "Option::is_none")] + pub templates: Option<Vec<ActionTemplate>>, + /// Additional information about the assertion. #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option<Metadata>, @@ -265,15 +376,29 @@ impl Actions { pub fn new() -> Self { Self { actions: Vec::new(), + templates: None, metadata: None, } } + /// determines if actions is V2 + fn is_v2(&self) -> bool { + if self.templates.is_some() { + return true; + }; + self.actions.iter().any(|a| a.is_v2()) + } + /// Returns the list of [`Action`]s. pub fn actions(&self) -> &[Action] { &self.actions } + /// Returns mutable list of [`Action`]s. + pub fn actions_mut(&mut self) -> &mut [Action] { + &mut self.actions + } + /// Returns the assertion's [`Metadata`], if it exists. pub fn metadata(&self) -> Option<&Metadata> { self.metadata.as_ref() @@ -308,6 +433,25 @@ impl AssertionCbor for Actions {} impl AssertionBase for Actions { const LABEL: &'static str = labels::ACTIONS; + const VERSION: Option<usize> = Some(ASSERTION_CREATION_VERSION); + + /// if we require v2 fields then use V2 + fn version(&self) -> Option<usize> { + if self.is_v2() { + Some(2) + } else { + Some(1) + } + } + + /// if we require v2 fields then use V2 + fn label(&self) -> &str { + if self.is_v2() { + "c2pa.actions.v2" + } else { + labels::ACTIONS + } + } fn to_assertion(&self) -> Result<Assertion> { Self::to_cbor_assertion(self) @@ -516,7 +660,8 @@ pub mod tests { "parameters": { "description": "gradient", "name": "any value" - } + }, + "softwareAgent": "TestApp" }, { "action": "c2pa.opened", @@ -524,7 +669,8 @@ pub mod tests { "parameters": { "description": "import" }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia" + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia", + "softwareAgent": "TestApp 1.0", }, ], "metadata": { @@ -534,7 +680,70 @@ pub mod tests { let original = Actions::from_json_value(&json).expect("from json"); let assertion = original.to_assertion().expect("build_assertion"); let result = Actions::from_assertion(&assertion).expect("extract_assertion"); - println!("{:?}", serde_json::to_string(&result)); + assert_eq!(result.label(), labels::ACTIONS); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + assert_eq!(original.actions, result.actions); + assert_eq!( + result.actions[0].software_agent().unwrap(), + &SoftwareAgent::String("TestApp".to_string()) + ); + } + + #[test] + fn test_json_v2_round_trip() { + let json = serde_json::json!({ + "actions": [ + { + "action": "c2pa.edited", + "parameters": { + "description": "gradient", + "name": "any value" + }, + "softwareAgent": "TestApp" + }, + { + "action": "c2pa.opened", + "instanceId": "xmp.iid:7b57930e-2f23-47fc-affe-0400d70b738d", + "parameters": { + "description": "import" + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia", + "softwareAgent": { + "name": "TestApp", + "version": "1.0", + "something": "else" + }, + }, + { + "action": "com.joesphoto.filter", + } + ], + "templates": [ + { + "action": "com.joesphoto.filter", + "description": "Magic Filter", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic", + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "schema.org.SoftwareApplication.operatingSystem": "Windows 10" + } + } + ], + "metadata": { + "mytag": "myvalue" + } + }); + let original = Actions::from_json_value(&json).expect("from json"); + let assertion = original.to_assertion().expect("build_assertion"); + let result = Actions::from_assertion(&assertion).expect("extract_assertion"); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + assert_eq!(result.label(), "c2pa.actions.v2"); assert_eq!(original.actions, result.actions); + assert_eq!(original.templates, result.templates); + assert_eq!( + result.actions[0].software_agent().unwrap(), + &SoftwareAgent::String("TestApp".to_string()) + ); } } diff --git a/sdk/src/assertions/ingredient.rs b/sdk/src/assertions/ingredient.rs @@ -23,7 +23,7 @@ use crate::{ validation_status::ValidationStatus, }; -const ASSERTION_CREATION_VERSION: usize = 1; +const ASSERTION_CREATION_VERSION: usize = 2; // Used to differentiate a parent from a component #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] @@ -34,6 +34,8 @@ pub enum Relationship { #[serde(rename = "componentOf")] #[default] ComponentOf, + #[serde(rename = "inputTo")] + InputTo, } /// An ingredient assertion @@ -45,8 +47,8 @@ pub struct Ingredient { pub format: String, #[serde(rename = "documentID", skip_serializing_if = "Option::is_none")] pub document_id: Option<String>, - #[serde(rename = "instanceID")] - pub instance_id: String, + #[serde(rename = "instanceID", skip_serializing_if = "Option::is_none")] + pub instance_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub c2pa_manifest: Option<HashedUri>, #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] @@ -56,6 +58,12 @@ pub struct Ingredient { pub thumbnail: Option<HashedUri>, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option<Metadata>, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option<HashedUri>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(rename = "informational_URI", skip_serializing_if = "Option::is_none")] + pub informational_uri: Option<String>, } impl Ingredient { @@ -69,15 +77,31 @@ impl Ingredient { title: title.to_owned(), format: format.to_owned(), document_id: document_id.map(|id| id.to_owned()), - instance_id: instance_id.to_owned(), - c2pa_manifest: None, - validation_status: None, - relationship: Relationship::ComponentOf, - thumbnail: None, - metadata: None, + instance_id: Some(instance_id.to_owned()), + ..Default::default() + } + } + + pub fn new_v2<S1, S2>(title: S1, format: S2) -> Self + where + S1: Into<String>, + S2: Into<String>, + { + Self { + title: title.into(), + format: format.into(), + ..Default::default() } } + /// determines if an ingredient is a v2 ingredient + fn is_v2(&self) -> bool { + self.instance_id.is_none() + || self.data.is_some() + || self.description.is_some() + || self.informational_uri.is_some() + } + pub fn set_parent(mut self) -> Self { self.relationship = Relationship::ParentOf; self @@ -127,6 +151,15 @@ impl AssertionBase for Ingredient { const LABEL: &'static str = Self::LABEL; const VERSION: Option<usize> = Some(ASSERTION_CREATION_VERSION); + /// if we require v2 fields then use V2 + fn version(&self) -> Option<usize> { + if self.is_v2() { + Some(2) + } else { + Some(1) + } + } + fn to_assertion(&self) -> Result<Assertion> { Self::to_cbor_assertion(self) } diff --git a/sdk/src/assertions/metadata.rs b/sdk/src/assertions/metadata.rs @@ -24,6 +24,7 @@ use crate::{ assertions::labels, error::Result, hashed_uri::HashedUri, + utils::cbor_types::DateT, }; const ASSERTION_CREATION_VERSION: usize = 1; @@ -35,7 +36,7 @@ pub struct Metadata { #[serde(rename = "reviewRatings", skip_serializing_if = "Option::is_none")] reviews: Option<Vec<ReviewRating>>, #[serde(rename = "dateTime", skip_serializing_if = "Option::is_none")] - date_time: Option<String>, + date_time: Option<DateT>, #[serde(skip_serializing_if = "Option::is_none")] reference: Option<HashedUri>, #[serde(skip_serializing_if = "Option::is_none")] @@ -53,7 +54,9 @@ impl Metadata { pub fn new() -> Self { Self { reviews: None, - date_time: Some(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)), + date_time: Some(DateT( + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + )), reference: None, data_source: None, other: HashMap::new(), @@ -94,7 +97,7 @@ impl Metadata { /// Sets the ISO 8601 date-time string when the assertion was created/generated. pub fn set_date_time(&mut self, date_time: String) -> &mut Self { - self.date_time = Some(date_time); + self.date_time = Some(DateT(date_time)); self } @@ -160,6 +163,7 @@ pub mod c2pa_source { /// A description of the source for assertion data #[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[non_exhaustive] pub struct DataSource { /// A value from among the enumerated list indicating the source of the assertion. #[serde(rename = "type")] @@ -199,6 +203,7 @@ impl DataSource { /// Identifies a person responsible for an action. #[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[non_exhaustive] pub struct Actor { /// An identifier for a human actor, used when the "type" is `humanEntry.identified`. #[serde(skip_serializing_if = "Option::is_none")] @@ -265,6 +270,24 @@ impl ReviewRating { } } +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +pub struct AssetType { + #[serde(rename = "type")] + pub asset_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option<String>, +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct DataBox { + #[serde(rename = "dc:format")] + pub format: String, + #[serde(with = "serde_bytes")] + pub data: Vec<u8>, + pub data_types: Option<Vec<AssetType>>, +} + #[cfg(test)] pub mod tests { #![allow(clippy::expect_used)] diff --git a/sdk/src/assertions/mod.rs b/sdk/src/assertions/mod.rs @@ -14,7 +14,7 @@ //! Assertion helpers to build, validate, and parse assertions. mod actions; -pub use actions::{c2pa_action, Action, Actions}; +pub use actions::{c2pa_action, Action, Actions, SoftwareAgent}; mod bmff_hash; pub use bmff_hash::{BmffHash, BmffMerkleMap, DataMap, ExclusionsMap, SubsetMap}; @@ -36,7 +36,9 @@ pub(crate) use ingredient::{Ingredient, Relationship}; pub mod labels; mod metadata; -pub use metadata::{c2pa_source, Actor, DataSource, Metadata, ReviewCode, ReviewRating}; +pub use metadata::{ + c2pa_source, Actor, AssetType, DataBox, DataSource, Metadata, ReviewCode, ReviewRating, +}; mod schema_org; pub use schema_org::{SchemaDotOrg, SchemaDotOrgPerson}; diff --git a/sdk/src/assertions/user_cbor.rs b/sdk/src/assertions/user_cbor.rs @@ -33,6 +33,10 @@ impl UserCbor { cbor_data: data, } } + + pub fn data(&self) -> &[u8] { + &self.cbor_data + } } impl AssertionBase for UserCbor { diff --git a/sdk/src/asset_handlers/bmff_io.rs b/sdk/src/asset_handlers/bmff_io.rs @@ -1611,6 +1611,7 @@ pub mod tests { use crate::utils::test::{fixture_path, temp_dir_path}; #[cfg(not(target_arch = "wasm32"))] + #[cfg(file_io)] #[test] fn test_read_mp4() { use crate::{ diff --git a/sdk/src/claim.rs b/sdk/src/claim.rs @@ -26,7 +26,7 @@ use crate::{ assertions::{ self, labels::{self, CLAIM}, - BmffHash, DataHash, + AssetType, BmffHash, DataBox, DataHash, }, asset_io::CAIRead, cose_validator::{get_signing_info, verify_cose, verify_cose_async}, @@ -37,13 +37,17 @@ use crate::{ boxes::{ CAICBORAssertionBox, CAIJSONAssertionBox, CAIUUIDAssertionBox, JumbfEmbeddedFileBox, }, - labels::{ASSERTIONS, CREDENTIALS, SIGNATURE}, + labels::{ + box_name_from_uri, manifest_label_from_uri, to_databox_uri, ASSERTIONS, CREDENTIALS, + DATABOX, DATABOXES, SIGNATURE, + }, }, salt::{DefaultSalt, SaltGenerator, NO_SALT}, status_tracker::{log_item, OneShotStatusTracker, StatusTracker}, utils::hash_utils::{hash_by_alg, vec_compare, verify_by_alg}, validation_status, validator::ValidationInfo, + ClaimGeneratorInfo, }; const BUILD_HASH_ALG: &str = "sha256"; @@ -64,11 +68,11 @@ pub enum ClaimAssetData<'a> { StreamFragment(&'a mut dyn CAIRead, &'a mut dyn CAIRead), } -#[derive(PartialEq, Eq, Clone)] // helper struct to allow arbitrary order for assertions stored in jumbf. The instance is // stored separate from the Assertion to allow for late binding to the label. Also, // we can load assertions in any order and know the position without re-parsing label. We also // save on parsing the cbor assertion each time we need its contents +#[derive(PartialEq, Eq, Clone)] pub struct ClaimAssertion { assertion: Assertion, instance: usize, @@ -157,6 +161,7 @@ impl fmt::Debug for ClaimAssertion { write!(f, "{:?}, instance: {}", self.assertion, self.instance) } } + /// A `Claim` gathers together all the `Assertion`s about an asset /// from an actor at a given time, and may also include one or more /// hashes of the asset itself, and a reference to the previous `Claim`. @@ -165,7 +170,7 @@ impl fmt::Debug for ClaimAssertion { /// assigned a label (`c2pa.claim.v1`) and being either embedded into the /// asset or in the cloud. The claim is cryptographically hashed and /// that hash is signed to produce the claim signature. -#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +#[derive(Deserialize, Serialize, Debug, Default, Clone)] pub struct Claim { // external manifest #[serde(skip_deserializing, skip_serializing)] @@ -197,6 +202,7 @@ pub struct Claim { // root of CAI store #[serde(skip_deserializing, skip_serializing)] + #[allow(dead_code)] root: String, // internal scratch objects @@ -215,6 +221,8 @@ pub struct Claim { claim_generator: String, // generator of this claim + pub(crate) claim_generator_info: Option<Vec<ClaimGeneratorInfo>>, /* detailed generator info of this claim */ + signature: String, // link to signature box assertions: Vec<C2PAAssertion>, // list of assertion hashed URIs @@ -237,6 +245,9 @@ pub struct Claim { #[serde(skip_serializing_if = "Option::is_none")] claim_generator_hints: Option<HashMap<String, Value>>, + + #[serde(skip_deserializing, skip_serializing)] + data_boxes: Vec<(HashedUri, DataBox)>, /* list of the data boxes and their hashed URIs found for this manifest */ } /// Enum to define how assertions are are stored when output to json @@ -305,6 +316,7 @@ impl Claim { signature: "".to_string(), claim_generator: claim_generator.into(), + claim_generator_info: None, assertion_store: Vec::new(), vc_store: Vec::new(), assertions: Vec::new(), @@ -320,6 +332,7 @@ impl Claim { instance_id: "".to_string(), update_manifest: false, + data_boxes: Vec::new(), } } @@ -337,6 +350,7 @@ impl Claim { signature: "".to_string(), claim_generator: claim_generator.into(), + claim_generator_info: None, assertion_store: Vec::new(), vc_store: Vec::new(), assertions: Vec::new(), @@ -352,6 +366,7 @@ impl Claim { instance_id: "".to_string(), update_manifest: false, + data_boxes: Vec::new(), } } @@ -437,7 +452,8 @@ impl Claim { /// order to process pub fn get_box_order(&self) -> &[&str] { - const DEFAULT_MANIFEST_ORDER: [&str; 4] = [ASSERTIONS, CLAIM, SIGNATURE, CREDENTIALS]; + const DEFAULT_MANIFEST_ORDER: [&str; 5] = + [ASSERTIONS, CLAIM, SIGNATURE, CREDENTIALS, DATABOXES]; if let Some(bo) = &self.original_box_order { bo @@ -498,6 +514,18 @@ impl Claim { self.update_manifest = is_update_manifest; } + pub fn add_claim_generator_info(&mut self, info: ClaimGeneratorInfo) -> &mut Self { + match self.claim_generator_info.as_mut() { + Some(cgi) => cgi.push(info), + None => self.claim_generator_info = Some([info].to_vec()), + } + self + } + + pub fn claim_generator_info(&self) -> Option<&[ClaimGeneratorInfo]> { + self.claim_generator_info.as_deref() + } + pub fn add_claim_generator_hint(&mut self, hint_key: &str, hint_value: Value) { if self.claim_generator_hints.is_none() { self.claim_generator_hints = Some(HashMap::new()); @@ -630,6 +658,113 @@ impl Claim { Ok(c2pa_assertion) } + // Add a new DataBox and return the HashedURI reference + pub fn add_databox( + &mut self, + format: &str, + data: Vec<u8>, + data_types: Option<Vec<AssetType>>, + ) -> Result<HashedUri> { + // create data box + let new_db = DataBox { + format: format.to_string(), + data, + data_types, + }; + + // serialize to cbor + let db_cbor = serde_cbor::to_vec(&new_db).map_err(|_err| Error::AssertionEncoding)?; + + // get the index for the new assertion + let mut index = 0; + for (uri, _db) in &self.data_boxes { + let (_l, i) = Claim::assertion_label_from_link(&uri.url()); + if i >= index { + index = i + 1; + } + } + + let label = Claim::label_with_instance(DATABOX, index); + let link = jumbf::labels::to_databox_uri(self.label(), &label); + + // salt box for 1.2 VC redaction support + let ds = DefaultSalt::default(); + let salt = ds.generate_salt(); + + // assertion JUMBF box hash for 1.2 validation + let assertion = Assertion::from_data_cbor(&label, &db_cbor); + let hash = Claim::calc_assertion_box_hash(&label, &assertion, salt.clone(), self.alg())?; + + let mut databox_uri = C2PAAssertion::new(link, Some(self.alg().to_string()), &hash); + databox_uri.add_salt(salt); + + // add credential to vcstore + self.data_boxes.push((databox_uri.clone(), new_db)); + + Ok(databox_uri) + } + + pub(crate) fn databoxes(&self) -> &Vec<(HashedUri, DataBox)> { + &self.data_boxes + } + + pub fn find_databox(&self, uri: &str) -> Option<&DataBox> { + self.data_boxes + .iter() + .find(|(h, _d)| h.url() == uri) + .map(|(_sh, data_box)| data_box) + } + + /// Load known VC with optional salt + pub(crate) fn put_data_box( + &mut self, + label: &str, + databox_cbor: &[u8], + salt: Option<Vec<u8>>, + ) -> Result<()> { + let link = jumbf::labels::to_databox_uri(self.label(), label); + + // assertion JUMBF box hash for 1.2 validation + let assertion = Assertion::from_data_cbor(label, databox_cbor); + let hash = Claim::calc_assertion_box_hash(label, &assertion, salt.clone(), self.alg())?; + + let mut uri = C2PAAssertion::new(link, Some(self.alg().to_string()), &hash); + uri.add_salt(salt); + + let db: DataBox = + serde_cbor::from_slice(databox_cbor).map_err(|_err| Error::AssertionEncoding)?; + + // add data box to data box store + self.data_boxes.push((uri, db)); + + Ok(()) + } + + pub fn get_data_box(&self, uri: &str) -> Option<&DataBox> { + // normalize uri + let normalized_uri = if let Some(manifest) = manifest_label_from_uri(uri) { + if manifest != self.label() { + return None; + } + uri.to_owned() + } else { + // make a full path + if let Some(box_name) = box_name_from_uri(uri) { + to_databox_uri(self.label(), &box_name) + } else { + return None; + } + }; + + self.data_boxes.iter().find_map(|x| { + if x.0.url() == normalized_uri { + Some(&x.1) + } else { + None + } + }) + } + pub(crate) fn vc_id(vc_json: &str) -> Result<String> { let vc: Value = serde_json::from_str(vc_json).map_err(|_err| Error::VerifiableCredentialInvalid)?; // check for json validity @@ -680,7 +815,7 @@ impl Claim { } /// Load known VC with optional salt - pub fn put_verifiable_credential( + pub(crate) fn put_verifiable_credential( &mut self, vc_json: &str, salt: Option<Vec<u8>>, @@ -1736,7 +1871,7 @@ pub mod tests { #![allow(clippy::unwrap_used)] use super::*; - use crate::utils::test::create_test_claim; + use crate::{resource_store::UriOrResource, utils::test::create_test_claim}; #[test] fn test_build_claim() { @@ -1794,4 +1929,29 @@ pub mod tests { assert_eq!(expected_value, value.as_str().unwrap()); } + + #[test] + fn test_build_claim_generator_info() { + // Create a new claim. + let mut claim = create_test_claim().expect("create test claim"); + + let mut info = ClaimGeneratorInfo::new("test app"); + info.version = Some("2.3.4".to_string()); + info.icon = Some(UriOrResource::HashedUri(HashedUri::new( + "self#jumbf=c2pa.databoxes.data_box".to_string(), + None, + b"hashed", + ))); + info.insert("something", "else"); + + claim.add_claim_generator_info(info); + + let cgi = claim.claim_generator_info().unwrap(); + + assert_eq!(&cgi[0].name, "test app"); + assert_eq!(cgi[0].version.as_deref(), Some("2.3.4")); + if let UriOrResource::HashedUri(r) = cgi[0].icon.as_ref().unwrap() { + assert_eq!(r.hash(), b"hashed"); + } + } } diff --git a/sdk/src/claim_generator_info.rs b/sdk/src/claim_generator_info.rs @@ -0,0 +1,125 @@ +// 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. +use std::collections::HashMap; + +#[cfg(feature = "json_schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::resource_store::UriOrResource; + +/// Description of the claim generator, or the software used in generating the claim. +/// +/// This structure is also used for actions softwareAgent +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +pub struct ClaimGeneratorInfo { + /// A human readable string naming the claim_generator + pub name: String, + /// A human readable string of the product's version + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option<String>, + /// hashed URI to the icon (either embedded or remote) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option<UriOrResource>, + // Any other values that are not part of the standard + #[serde(flatten)] + other: HashMap<String, Value>, +} + +impl ClaimGeneratorInfo { + pub fn new<S: Into<String>>(name: S) -> Self { + Self { + name: name.into(), + version: None, + icon: None, + other: HashMap::new(), + } + } + + /// Returns the software agent that performed the action. + pub fn icon(&self) -> Option<&UriOrResource> { + self.icon.as_ref() + } + + /// Sets the version of the generator. + pub fn set_version<S: Into<String>>(&mut self, version: S) -> &mut Self { + self.version = Some(version.into()); + self + } + + /// Sets the icon of the generator. + pub fn set_icon<S: Into<UriOrResource>>(&mut self, uri_or_resource: S) -> &mut Self { + self.icon = Some(uri_or_resource.into()); + self + } + + /// Adds a new key/value pair to the generator info. + pub fn insert<K, V>(&mut self, key: K, value: V) -> &Self + where + K: Into<String>, + V: Into<Value>, + { + self.other.insert(key.into(), value.into()); + self + } + + /// Gets additional values by key. + pub fn get(&self, key: &str) -> Option<&Value> { + self.other.get(key) + } +} + +#[cfg(test)] +pub mod tests { + #![allow(clippy::expect_used)] + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::{hashed_uri::HashedUri, resource_store::ResourceRef}; + + #[test] + fn test_resource_ref() { + let mut g = super::ClaimGeneratorInfo::new("test"); + g.set_version("1.0") + .set_icon(ResourceRef::new("image/svg", "myicon")); + + let json = serde_json::to_string_pretty(&g).expect("Failed to serialize"); + println!("{json}"); + + let result: ClaimGeneratorInfo = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(g, result); + } + + #[test] + fn test_hashed_uri() { + let mut g = super::ClaimGeneratorInfo::new("test"); + g.set_version("1.0").set_icon(HashedUri::new( + "self#jumbf=c2pa.databoxes.data_box".to_string(), + None, + b"hashed", + )); + + let json = serde_json::to_string_pretty(&g).expect("Failed to serialize"); + println!("{json}"); + + let result: ClaimGeneratorInfo = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(g, result); + } +} diff --git a/sdk/src/cose_validator.rs b/sdk/src/cose_validator.rs @@ -953,8 +953,6 @@ pub fn verify_cose( log_item!("Cose_Sign1", "error parsing timestamp", "verify_cose") .error(Error::CoseInvalidTimeStamp); validation_log.log(log_item, Some(Error::CoseInvalidTimeStamp))?; - - return Err(Error::CoseInvalidTimeStamp); } } } diff --git a/sdk/src/error.rs b/sdk/src/error.rs @@ -24,6 +24,10 @@ pub enum Error { #[error("claim missing: label = {label}")] ClaimMissing { label: String }, + /// An assertion has an unsupported version + #[error("Unsupported Assertion version")] + AssertionUnsupportedVersion, + /// An assertion could not be found at the expected URL. #[error("assertion missing: url = {url}")] AssertionMissing { url: String }, @@ -239,6 +243,9 @@ pub enum Error { #[error("could not parse ECDSA signature")] InvalidEcdsaSignature, + #[error("missing data box")] + MissingDataBox, + #[error("could not generate XML")] XmlWriteError, diff --git a/sdk/src/hashed_uri.rs b/sdk/src/hashed_uri.rs @@ -17,7 +17,7 @@ use std::fmt; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// Hashed Uri stucture as defined by C2PA spec +/// Hashed Uri structure as defined by C2PA spec /// It is annotated to produce the correctly tagged cbor serialization #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs @@ -54,8 +54,9 @@ pub struct Ingredient { document_id: Option<String>, /// Instance ID from `xmpMM:InstanceID` in XMP metadata. - #[serde(default = "default_instance_id")] - instance_id: String, + //#[serde(default = "default_instance_id")] + #[serde(skip_serializing_if = "Option::is_none")] + instance_id: Option<String>, /// URI from `dcterms:provenance` in XMP metadata. #[serde(skip_serializing_if = "Option::is_none")] @@ -74,7 +75,6 @@ pub struct Ingredient { /// Set to `ParentOf` if this is the parent ingredient. /// /// There can only be one parent ingredient in the ingredients. - // #[serde(skip_serializing_if = "Option::is_none")] // is_parent: Option<bool>, #[serde(default = "default_relationship")] relationship: Relationship, @@ -93,6 +93,18 @@ pub struct Ingredient { #[serde(skip_serializing_if = "Option::is_none")] validation_status: Option<Vec<ValidationStatus>>, + /// A reference to the actual data of the ingredient. + #[serde(skip_serializing_if = "Option::is_none")] + data: Option<ResourceRef>, + + /// Additional description of the ingredient. + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<String>, + + /// URI to an informational page about the ingredient or its data. + #[serde(rename = "informational_URI", skip_serializing_if = "Option::is_none")] + informational_uri: Option<String>, + /// Any additional [`Metadata`] as defined in the C2PA spec. /// /// [`Manifest`]: crate::Manifest @@ -144,7 +156,32 @@ impl Ingredient { Self { title: title.into(), format: format.into(), - instance_id: instance_id.into(), + instance_id: Some(instance_id.into()), + ..Default::default() + } + } + + /// Constructs a new V2 `Ingredient`. + /// + /// # Arguments + /// + /// * `title` - A user-displayable name for this ingredient (often a filename). + /// * `format` - The MIME media type of the ingredient - i.e. `image/jpeg`. + /// + /// # Examples + /// + /// ``` + /// use c2pa::Ingredient; + /// let ingredient = Ingredient::new_v2("title", "image/jpeg"); + /// ``` + pub fn new_v2<S1, S2>(title: S1, format: S2) -> Self + where + S1: Into<String>, + S2: Into<String>, + { + Self { + title: title.into(), + format: format.into(), ..Default::default() } } @@ -165,8 +202,10 @@ impl Ingredient { } /// Returns the instance identifier. + /// + /// For v2 ingredients this can return an empty string pub fn instance_id(&self) -> &str { - self.instance_id.as_str() + self.instance_id.as_deref().unwrap_or("") } /// Returns the provenance uri if available. @@ -204,6 +243,11 @@ impl Ingredient { self.relationship == Relationship::ParentOf } + /// Returns the relationship status of the ingredient. + pub fn relationship(&self) -> &Relationship { + &self.relationship + } + /// Returns a reference to the [`ValidationStatus`]s if they exist. pub fn validation_status(&self) -> Option<&[ValidationStatus]> { self.validation_status.as_deref() @@ -240,12 +284,37 @@ impl Ingredient { .and_then(|r| self.resources.get(&r.identifier).ok()) } + /// Returns a reference to ingredient data if it exists. + pub fn data_ref(&self) -> Option<&ResourceRef> { + self.data.as_ref() + } + + /// Returns the detailed description of the ingredient if it exists. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Returns an informational uri for the ingredient if it exists. + pub fn informational_uri(&self) -> Option<&str> { + self.informational_uri.as_deref() + } + /// Sets a human-readable title for this ingredient. pub fn set_title<S: Into<String>>(&mut self, title: S) -> &mut Self { self.title = title.into(); self } + /// Sets the document instanceId. + /// + /// This call is optional for v2 ingredients. + /// + /// Typically this is found in XMP under `xmpMM:InstanceID`. + pub fn set_instance_id<S: Into<String>>(&mut self, instance_id: S) -> &mut Self { + self.instance_id = Some(instance_id.into()); + self + } + /// Sets the document identifier. /// /// This call is optional. @@ -275,6 +344,15 @@ impl Ingredient { self } + /// Set the ingredient Relationship status. + /// + /// Only one ingredient should be set as a parentOf. + /// Use Manifest.set_parent to ensure this is the only parent ingredient + pub fn set_relationship(&mut self, relationship: Relationship) -> &mut Self { + self.relationship = relationship; + self + } + /// Sets the thumbnail from a ResourceRef. pub fn set_thumbnail_ref(&mut self, thumbnail: ResourceRef) -> Result<&mut Self> { // verify the resource referenced exists @@ -345,7 +423,7 @@ impl Ingredient { self } - /// Sets a reference to Manifest C2PA data - does not verify the resource exists + /// Sets a reference to Manifest C2PA data 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) { @@ -362,6 +440,28 @@ impl Ingredient { Ok(self) } + /// Sets a reference to Ingredient data + pub fn set_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.data = Some(data_ref); + Ok(self) + } + + /// Sets a detailed description for this ingredient + pub fn set_description<S: Into<String>>(&mut self, description: S) -> &mut Self { + self.description = Some(description.into()); + self + } + + /// Sets an informational uri if needed + pub fn set_informational_uri<S: Into<String>>(&mut self, uri: S) -> &mut Self { + self.informational_uri = Some(uri.into()); + self + } + /// Return an immutable reference to the ingredient resources pub fn resources(&self) -> &ResourceStore { &self.resources @@ -791,8 +891,10 @@ impl Ingredient { } /// Creates an Ingredient from a store and a URI to an ingredient assertion. + /// claim_label identifies the claim for relative paths pub(crate) fn from_ingredient_uri( store: &Store, + claim_label: &str, ingredient_uri: &str, #[cfg(feature = "file_io")] resource_path: Option<&Path>, ) -> Result<Self> { @@ -816,8 +918,8 @@ impl Ingredient { let thumbnail = ingredient_assertion.thumbnail.and_then(|hashed_uri| { // This could be a relative or absolute thumbnail reference to another manifest let target_label = match jumbf::labels::manifest_label_from_uri(&hashed_uri.url()) { - Some(label) => label, // use the manifest from the thumbnail uri - None => ingredient_uri.to_owned(), // relative so use the whole url from the thumbnail assertion + Some(label) => label, // use the manifest from the thumbnail uri + None => claim_label.to_owned(), // relative so use the whole url from the thumbnail assertion }; match store.get_assertion_from_uri_and_claim(&hashed_uri.url(), &target_label) { Some(assertion) => Some(Self::thumbnail_from_assertion(assertion)), @@ -841,7 +943,9 @@ impl Ingredient { let mut ingredient = Ingredient::new( &ingredient_assertion.title, &ingredient_assertion.format, - &ingredient_assertion.instance_id, + &ingredient_assertion + .instance_id + .unwrap_or_else(default_instance_id), ); ingredient.document_id = ingredient_assertion.document_id; @@ -854,12 +958,34 @@ impl Ingredient { ingredient.set_thumbnail(format, image)?; } + if let Some(data_uri) = ingredient_assertion.data.as_ref() { + let data_box = store + .get_data_box_from_uri_and_claim(&data_uri.url(), claim_label) + .ok_or_else(|| { + error!("failed to get {} from {}", data_uri.url(), ingredient_uri); + Error::AssertionMissing { + url: data_uri.url(), + } + })?; + + let id_base = ingredient.instance_id().to_owned(); + let mut data_ref = ingredient.resources_mut().add_with( + &id_base, + &data_box.format, + data_box.data.clone(), + )?; + data_ref.data_types = data_box.data_types.clone(); + ingredient.set_data_ref(data_ref)?; + } + ingredient.relationship = ingredient_assertion.relationship; ingredient.active_manifest = active_manifest; if !validation_status.is_empty() { ingredient.validation_status = Some(validation_status) } ingredient.metadata = ingredient_assertion.metadata; + ingredient.description = ingredient_assertion.description; + ingredient.informational_uri = ingredient_assertion.informational_uri; Ok(ingredient) } @@ -892,11 +1018,15 @@ impl Ingredient { }; // get the c2pa manifest bytes - let data = self.resources.get(&resource_ref.identifier)?; + let manifest_data = self.resources.get(&resource_ref.identifier)?; // have Store check and load ingredients and add them to a claim - let ingredient_store = - Store::load_ingredient_to_claim(claim, &manifest_label, &data, redactions)?; + let ingredient_store = Store::load_ingredient_to_claim( + claim, + &manifest_label, + &manifest_data, + redactions, + )?; // get the ingredient map loaded in previous match claim.claim_ingredient(&manifest_label) { @@ -961,18 +1091,44 @@ impl Ingredient { thumbnail = Some(hash_url); } - let mut ingredient_assertion = assertions::Ingredient::new( - &self.title, - &self.format, - &self.instance_id, - self.document_id.as_deref(), - ); + let mut data = None; + if let Some(data_ref) = self.data_ref() { + let box_data = self.resources.get(&data_ref.identifier)?; + let hash_url = claim.add_databox( + &data_ref.format, + box_data.into_owned(), + data_ref.data_types.clone(), + )?; + + data = Some(hash_url); + }; + + // instance_id is required in V1 so we generate one if it's not provided + let instance_id = match self.instance_id.as_ref() { + Some(id) => Some(id.to_owned()), + None => { + if self.data.is_some() + || self.description.is_some() + || self.informational_uri.is_some() + { + None // not required in V2 + } else { + Some(default_instance_id()) + } + } + }; + let mut ingredient_assertion = assertions::Ingredient::new_v2(&self.title, &self.format); + ingredient_assertion.instance_id = instance_id; + ingredient_assertion.document_id = self.document_id.to_owned(); ingredient_assertion.c2pa_manifest = c2pa_manifest; ingredient_assertion.relationship = self.relationship.clone(); ingredient_assertion.thumbnail = thumbnail; ingredient_assertion.metadata = self.metadata.clone(); ingredient_assertion.validation_status = self.validation_status.clone(); + ingredient_assertion.data = data; + ingredient_assertion.description = self.description.clone(); + ingredient_assertion.informational_uri = self.informational_uri.clone(); claim.add_assertion(&ingredient_assertion) } @@ -1151,17 +1307,26 @@ mod tests { fn test_ingredient_api() { let mut ingredient = Ingredient::new("title", "format", "instance_id"); ingredient + .resources_mut() + .add("id", "data".as_bytes().to_vec()) + .expect("add"); + ingredient .set_document_id("document_id") .set_title("title2") .set_hash("hash") .set_provenance("provenance") .set_is_parent() + .set_relationship(Relationship::ParentOf) .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") + .set_description("description") + .set_informational_uri("uri") + .set_data_ref(ResourceRef::new("format", "id")) + .expect("set_data_ref") .add_validation_status(ValidationStatus::new("status_code")); assert_eq!(ingredient.title(), "title2"); assert_eq!(ingredient.format(), "format"); @@ -1170,6 +1335,11 @@ mod tests { assert_eq!(ingredient.provenance(), Some("provenance")); assert_eq!(ingredient.hash(), Some("hash")); assert!(ingredient.is_parent()); + assert_eq!(ingredient.relationship(), &Relationship::ParentOf); + assert_eq!(ingredient.description(), Some("description")); + assert_eq!(ingredient.informational_uri(), Some("uri")); + assert_eq!(ingredient.data_ref().unwrap().format, "format"); + assert_eq!(ingredient.data_ref().unwrap().identifier, "id"); assert!(ingredient.metadata().is_some()); assert_eq!(ingredient.thumbnail().unwrap().0, "format"); assert_eq!( @@ -1562,4 +1732,52 @@ mod tests_file_io { .is_ok()); assert!(ingredient.manifest_data_ref().is_some()); } + + #[test] + fn test_input_to_ingredient() { + // create an inputTo ingredient + let mut ingredient = Ingredient::new_v2("prompt", "text/plain"); + ingredient.relationship = Relationship::InputTo; + + // add a resource containing our data + ingredient + .resources_mut() + .add("prompt_id", "pirate with bird on shoulder") + .expect("add"); + + // create a resource reference for the data + let mut data_ref = ResourceRef::new("text/plain", "prompt_id"); + let data_type = crate::assertions::AssetType { + asset_type: "c2pa.types.generator.prompt".to_string(), + version: None, + }; + data_ref.data_types = Some([data_type].to_vec()); + + // add the data reference to the ingredient + ingredient.set_data_ref(data_ref).expect("set_data_ref"); + + println!("ingredient = {ingredient}"); + + assert_eq!(ingredient.title(), "prompt"); + assert_eq!(ingredient.format(), "text/plain"); + assert_eq!(ingredient.instance_id(), ""); + assert_eq!(ingredient.data_ref().unwrap().identifier, "prompt_id"); + assert_eq!(ingredient.data_ref().unwrap().format, "text/plain"); + assert_eq!(ingredient.relationship(), &Relationship::InputTo); + assert_eq!( + ingredient.data_ref().unwrap().data_types.as_ref().unwrap()[0].asset_type, + "c2pa.types.generator.prompt" + ); + } + + #[test] + #[cfg(feature = "file_io")] + fn test_input_to_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); + //let mut _data_ref = ResourceRef::new("image/jpg", "foo"); + //data_ref.data_types = vec!["c2pa.types.dataset.pytorch".to_string()]; + } } diff --git a/sdk/src/jumbf/boxes.rs b/sdk/src/jumbf/boxes.rs @@ -788,8 +788,9 @@ pub const CAI_SIGNATURE_UUID: &str = "6332637300110010800000AA00389B71"; // c2cs pub const CAI_EMBEDDED_FILE_UUID: &str = "40CB0C32BB8A489DA70B2AD6F47F4369"; pub const CAI_EMBEDDED_FILE_DESCRIPTION_UUID: &str = "6266646200110010800000AA00389B71"; // bfdb pub const CAI_EMBEDED_FILE_DATA_UUID: &str = "6269646200110010800000AA00389B71"; // bidb -pub const CAI_VERIFIABLE_CREDENTIALS_STORE_UUID: &str = "6332766300110010800000AA00389B71"; //c2vc +pub const CAI_VERIFIABLE_CREDENTIALS_STORE_UUID: &str = "6332766300110010800000AA00389B71"; // c2vc pub const CAI_UUID_ASSERTION_UUID: &str = "7575696400110010800000AA00389B71"; // uuid +pub const CAI_DATABOXES_STORE_UUID: &str = "6332646200110010800000AA00389B71"; // c2db // ANCHOR Salt Content Box /// Salt Content Box @@ -1242,6 +1243,58 @@ impl Default for CAIAssertionStore { } } +#[derive(Debug)] +pub struct CAIDataboxStore { + store: JUMBFSuperBox, +} + +impl BMFFBox for CAIDataboxStore { + fn box_type(&self) -> &'static [u8; 4] { + b" " + } + + fn box_uuid(&self) -> &'static str { + CAI_DATABOXES_STORE_UUID + } + + fn box_payload_size(&self) -> IoResult<u32> { + let size = boxio::ByteCounter::calculate(|w| self.write_box_payload(w))?; + Ok(size as u32) + } + + fn write_box_payload(&self, writer: &mut dyn Write) -> IoResult<()> { + self.store.write_box(writer) + } + + // Necessary method to enable conversion between types... + fn as_any(&self) -> &dyn Any { + self + } +} + +impl CAIDataboxStore { + pub fn new() -> Self { + CAIDataboxStore { + store: JUMBFSuperBox::new(labels::DATABOXES, Some(CAI_DATABOXES_STORE_UUID)), + } + } + + pub fn from(in_box: JUMBFSuperBox) -> Self { + CAIDataboxStore { store: in_box } + } + + // add an assertion box (of various types) *WITHOUT* taking ownership of the box + pub fn add_databox(&mut self, b: Box<dyn BMFFBox>) { + self.store.add_data_box(b) + } +} + +impl Default for CAIDataboxStore { + fn default() -> Self { + Self::new() + } +} + // ANCHOR Verifiable Credential Store /// Ingredients Store #[derive(Debug)] diff --git a/sdk/src/jumbf/labels.rs b/sdk/src/jumbf/labels.rs @@ -45,6 +45,16 @@ pub const SIGNATURE: &str = "c2pa.signature"; /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_credential_storage>. pub const CREDENTIALS: &str = "c2pa.credentials"; +/// Label for the DataBox box. +/// +/// See <https://c2pa.org/specifications/specifications/1.3/specs/C2PA_Specification.html#_data_boxes>. +pub const DATABOX: &str = "c2pa.data"; + +/// Label for the DataBox store box. +/// +/// See <https://c2pa.org/specifications/specifications/1.3/specs/C2PA_Specification.html#_data_storage>. +pub const DATABOXES: &str = "c2pa.databoxes"; + const JUMBF_PREFIX: &str = "self#jumbf"; // Converts a manifest label to a JUMBF URI. @@ -79,6 +89,18 @@ pub(crate) fn to_verifiable_credential_uri(manifest_label: &str, vc_id: &str) -> ) } +// Converts a manifest label and a DataBox label to a JUMBF +// HashedURI. +pub(crate) fn to_databox_uri(manifest_label: &str, databox_id: &str) -> String { + // TO CONSIDER: Does this now belong in jumbf::labels? + format!( + "{}/{}/{}", + to_manifest_uri(manifest_label), + DATABOXES, + databox_id + ) +} + // Split off JUMBF prefix. pub(crate) fn to_normalized_uri(uri: &str) -> String { let uri_parts: Vec<&str> = uri.split('=').collect(); diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs @@ -126,6 +126,9 @@ pub(crate) mod asset_io; /// crate private declarations pub(crate) mod claim; +mod claim_generator_info; +pub use claim_generator_info::ClaimGeneratorInfo; + pub mod cose_sign; #[cfg(all(feature = "xmp_write", feature = "file_io"))] @@ -138,6 +141,7 @@ pub(crate) mod status_tracker; pub(crate) mod store; pub(crate) mod time_stamp; pub(crate) mod utils; +pub use utils::cbor_types::DateT; pub mod validation_status; pub(crate) use utils::{cbor_types, hash_utils}; pub(crate) mod validator; diff --git a/sdk/src/manifest.rs b/sdk/src/manifest.rs @@ -25,8 +25,8 @@ use uuid::Uuid; #[cfg(feature = "file_io")] use crate::AsyncSigner; use crate::{ - assertion::{AssertionBase, AssertionData}, - assertions::{labels, Actions, CreativeWork, Exif, Thumbnail, User, UserCbor}, + assertion::{AssertionBase, AssertionData, AssertionDecodeError}, + assertions::{labels, Actions, CreativeWork, Exif, SoftwareAgent, Thumbnail, User, UserCbor}, asset_io::CAIRead, claim::{Claim, RemoteManifest}, error::{Error, Result}, @@ -34,7 +34,7 @@ use crate::{ resource_store::{skip_serializing_resources, ResourceRef, ResourceStore}, salt::DefaultSalt, store::Store, - Ingredient, ManifestAssertion, ManifestAssertionKind, RemoteSigner, Signer, + ClaimGeneratorInfo, Ingredient, ManifestAssertion, ManifestAssertionKind, RemoteSigner, Signer, }; /// A Manifest represents all the information in a c2pa manifest @@ -51,6 +51,10 @@ pub struct Manifest { #[serde(default = "default_claim_generator")] pub claim_generator: String, + /// + #[serde(skip_serializing_if = "Option::is_none")] + pub claim_generator_info: Option<Vec<ClaimGeneratorInfo>>, + /// A human-readable title, generally source filename. #[serde(skip_serializing_if = "Option::is_none")] title: Option<String>, @@ -509,6 +513,7 @@ impl Manifest { // extract vendor from claim label let claim_generator = claim.claim_generator().to_owned(); + let mut manifest = Manifest::new(claim_generator); #[cfg(feature = "file_io")] @@ -516,6 +521,23 @@ impl Manifest { manifest.with_base_path(base_path)?; } + if let Some(info_vec) = claim.claim_generator_info() { + let mut generators = Vec::new(); + let id_base = manifest.instance_id().to_owned(); + for claim_info in info_vec { + let mut info = claim_info.to_owned(); + if let Some(icon) = claim_info.icon.as_ref() { + info.set_icon(icon.to_resource_ref( + manifest.resources_mut(), + claim, + &id_base, + )?); + } + generators.push(info); + } + manifest.claim_generator_info = Some(generators); + } + manifest.set_label(claim.label()); manifest.claim_generator_hints = claim.get_claim_generator_hint_map().cloned(); @@ -539,33 +561,86 @@ impl Manifest { .collect() }); - // let title = claim.title().map_or("".to_owned(), |s| s.to_owned()); - // let format = claim.format().to_owned(); - // let instance_id = claim.instance_id().to_owned(); if let Some(title) = claim.title() { manifest.set_title(title); } manifest.set_format(claim.format()); manifest.set_instance_id(claim.instance_id()); - //let mut asset = Ingredient::new(&title, &format, &instance_id); - for claim_assertion in claim.claim_assertion_store().iter() { let assertion = claim_assertion.assertion(); let label = claim_assertion.label(); let base_label = assertion.label(); debug!("assertion = {}", &label); match base_label.as_ref() { - labels::INGREDIENT => { + base if base.starts_with(labels::ACTIONS) => { + let mut actions = Actions::from_assertion(assertion)?; + let id = manifest.instance_id().to_owned(); + + for action in actions.actions_mut() { + if let Some(SoftwareAgent::ClaimGeneratorInfo(info)) = + action.software_agent_mut() + { + if let Some(icon) = info.icon.as_mut() { + let icon = icon.to_resource_ref( + manifest.resources_mut(), + claim, + id.as_str(), + )?; + info.set_icon(icon); + } + } + } + + // convert icons in templates to resource refs + if let Some(templates) = actions.templates.as_mut() { + for template in templates { + // replace icon with resource ref + template.icon = match template.icon.take() { + Some(icon) => Some(icon.to_resource_ref( + manifest.resources_mut(), + claim, + id.as_str(), + )?), + None => None, + }; + + // replace software agent with resource ref + template.software_agent = match template.software_agent.take() { + Some(SoftwareAgent::ClaimGeneratorInfo(mut info)) => { + if let Some(icon) = info.icon.as_mut() { + let icon = icon.to_resource_ref( + manifest.resources_mut(), + claim, + id.as_str(), + )?; + info.set_icon(icon); + } + Some(SoftwareAgent::ClaimGeneratorInfo(info)) + } + agent => agent, + }; + } + } + let manifest_assertion = ManifestAssertion::from_assertion(&actions)? + .set_instance(claim_assertion.instance()); + manifest.assertions.push(manifest_assertion); + } + base if base.starts_with(labels::INGREDIENT) => { + // note that we use the original label here, not the base label let assertion_uri = jumbf::labels::to_assertion_uri(claim.label(), &label); let ingredient = Ingredient::from_ingredient_uri( store, + manifest_label, &assertion_uri, #[cfg(feature = "file_io")] resource_path, )?; manifest.add_ingredient(ingredient); } + labels::DATA_HASH | labels::BMFF_HASH => { + // do not include data hash when reading manifests + } label if label.starts_with(labels::CLAIM_THUMBNAIL) => { let thumbnail = Thumbnail::from_assertion(assertion)?; manifest.set_thumbnail(thumbnail.content_type, thumbnail.data)?; @@ -573,18 +648,22 @@ impl Manifest { _ => { // inject assertions for all other assertions match assertion.decode_data() { - AssertionData::Json(_x) => { - let value = assertion.as_json_object()?; + AssertionData::Json(x) => { + let value = serde_json::from_str(x).map_err(|e| { + AssertionDecodeError::from_assertion_and_json_err(assertion, e) + })?; let ma = ManifestAssertion::new(base_label, value) .set_instance(claim_assertion.instance()) .set_kind(ManifestAssertionKind::Json); manifest.assertions.push(ma); } - AssertionData::Cbor(_x) => { - let value = assertion.as_json_object()?; //todo: should this be cbor? + AssertionData::Cbor(x) => { + let value: Value = + serde_cbor::from_slice(x.as_slice()).map_err(|e| { + AssertionDecodeError::from_assertion_and_cbor_err(assertion, e) + })?; let ma = ManifestAssertion::new(base_label, value) .set_instance(claim_assertion.instance()); - manifest.assertions.push(ma); } // todo: support binary forms @@ -658,6 +737,16 @@ impl Manifest { None => Claim::new(&generator, self.vendor.as_deref()), }; + if let Some(info_vec) = self.claim_generator_info.as_ref() { + for info in info_vec { + let mut claim_info = info.to_owned(); + if let Some(icon) = claim_info.icon.as_ref() { + claim_info.icon = Some(icon.to_hashed_uri(self.resources(), &mut claim)?); + } + claim.add_claim_generator_info(claim_info); + } + } + if let Some(remote_op) = &self.remote_manifest { match remote_op { RemoteManifest::NoRemote => (), @@ -703,16 +792,25 @@ impl Manifest { // add any additional assertions for manifest_assertion in &self.assertions { match manifest_assertion.label() { - Actions::LABEL => { + l if l.starts_with(Actions::LABEL) => { + let version = labels::version(l); + let mut actions: Actions = manifest_assertion.to_assertion()?; + let ingredients_key = match version { + None | Some(1) => "ingredient", + Some(2) => "ingredients", + _ => return Err(Error::AssertionUnsupportedVersion), + }; + // fixup parameters field from instance_id to ingredient uri let needs_ingredient: Vec<(usize, crate::assertions::Action)> = actions .actions() .iter() .enumerate() .filter_map(|(i, a)| { - if a.instance_id().is_some() && a.get_parameter("ingredient").is_none() + if a.instance_id().is_some() + && a.get_parameter(ingredients_key).is_none() { Some((i, a.clone())) } else { @@ -724,13 +822,65 @@ impl Manifest { for (index, action) in needs_ingredient { if let Some(id) = action.instance_id() { if let Some(hash_url) = ingredient_map.get(id) { - let update = - action.set_parameter("ingredient", hash_url.clone())?; + let update = match ingredients_key { + "ingredient" => { + action.set_parameter(ingredients_key, hash_url.clone()) + } + _ => { + // we only support on instanceId for actions, so only one ingredient on writing + action.set_parameter(ingredients_key, [hash_url.clone()]) + } + }?; actions = actions.update_action(index, update); } } } + if let Some(templates) = actions.templates.as_mut() { + for template in templates { + // replace icon with hashed_uri + template.icon = match template.icon.take() { + Some(icon) => { + Some(icon.to_hashed_uri(self.resources(), &mut claim)?) + } + None => None, + }; + + // replace software agent with hashed_uri + template.software_agent = match template.software_agent.take() { + Some(SoftwareAgent::ClaimGeneratorInfo(mut info)) => { + if let Some(icon) = info.icon.as_mut() { + let icon = + icon.to_hashed_uri(self.resources(), &mut claim)?; + info.set_icon(icon); + } + Some(SoftwareAgent::ClaimGeneratorInfo(info)) + } + agent => agent, + }; + } + } + + // convert icons in software agents to hashed uris + let actions_mut = actions.actions_mut(); + #[allow(clippy::needless_range_loop)] + // clippy is wrong here, we reference index twice + for index in 0..actions_mut.len() { + let action = &actions_mut[index]; + if let Some(SoftwareAgent::ClaimGeneratorInfo(info)) = + action.software_agent() + { + if let Some(icon) = info.icon.as_ref() { + let mut info = info.to_owned(); + let icon_uri = icon.to_hashed_uri(self.resources(), &mut claim)?; + let update = info.set_icon(icon_uri); + let mut action = action.to_owned(); + action = action.set_software_agent(update.to_owned()); + actions_mut[index] = action; + } + } + } + claim.add_assertion(&actions) } CreativeWork::LABEL => { @@ -887,7 +1037,7 @@ impl Manifest { } /// Embed a signed manifest into a stream using a supplied signer. - /// returns the bytes of the manifest that was embedded + /// returns the bytes of the new asset pub fn embed_stream( &mut self, format: &str, @@ -1021,6 +1171,7 @@ pub struct SignatureInfo { #[serde(skip_serializing_if = "Option::is_none")] time: Option<String>, } + #[cfg(test)] pub(crate) mod tests { #![allow(clippy::expect_used)] @@ -1675,11 +1826,65 @@ pub(crate) mod tests { #[cfg(feature = "file_io")] const MANIFEST_JSON: &str = r#"{ "claim_generator": "test", + "claim_generator_info": [ + { + "name": "test", + "version": "1.0", + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + } + } + ], "format" : "image/jpeg", "thumbnail": { "format": "image/jpeg", "identifier": "IMG_0003.jpg" }, + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "instanceId": "xmp.iid:7b57930e-2f23-47fc-affe-0400d70b738d", + "parameters": { + "description": "import" + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia", + "softwareAgent": { + "name": "TestApp", + "version": "1.0", + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + }, + "something": "else" + } + } + ], + "templates": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "TestApp", + "version": "1.0", + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + }, + "something": "else" + }, + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + } + } + ] + } + } + ], "ingredients": [{ "title": "A.jpg", "format": "image/jpeg", @@ -1689,23 +1894,46 @@ pub(crate) mod tests { "format": "image/png", "identifier": "exp-test1.png" } - }] + }, + { + "title": "prompt", + "format": "text/plain", + "relationship": "inputTo", + "data": { + "format": "text/plain", + "identifier": "prompt.txt", + "data_types": [ + { + "type": "c2pa.types.generator.prompt" + } + ] + } + } + ] }"#; #[test] #[cfg(feature = "openssl_sign")] /// tests and illustrates how to add assets to a non-file based manifest fn from_json_with_memory() { + use crate::assertions::Relationship; + 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") + .unwrap() + .add("sample1.svg", *b"my value") .expect("add resource"); manifest.ingredients_mut()[0] .resources_mut() .add("exp-test1.png", *b"my value") .expect("add_resource"); + manifest.ingredients_mut()[1] + .resources_mut() + .add("prompt.txt", *b"pirate with bird on shoulder") + .expect("add_resource"); println!("{manifest}"); @@ -1721,12 +1949,24 @@ pub(crate) mod tests { let manifest_store = crate::ManifestStore::from_bytes("jpeg", &output_image, true).expect("from_bytes"); + println!("manifest_store = {manifest_store}"); let m = manifest_store.get_active().unwrap(); + //println!("after = {m}"); + assert!(m.thumbnail().is_some()); let (format, image) = m.thumbnail().unwrap(); assert_eq!(format, "image/jpeg"); assert_eq!(image.to_vec(), b"my value"); + assert_eq!(m.ingredients().len(), 2); + assert_eq!(m.ingredients()[1].relationship(), &Relationship::InputTo); + assert!(m.ingredients()[1].data_ref().is_some()); + assert_eq!(m.ingredients()[1].data_ref().unwrap().format, "text/plain"); + let id = m.ingredients()[1].data_ref().unwrap().identifier.as_str(); + assert_eq!( + m.ingredients()[1].resources().get(id).unwrap().into_owned(), + b"pirate with bird on shoulder" + ); // println!("{manifest_store}"); } @@ -1737,7 +1977,6 @@ pub(crate) mod tests { 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")); @@ -1748,6 +1987,7 @@ pub(crate) mod tests { Some(&resource_path), ) .expect("from store"); + println!("{m2}"); assert!(m2.thumbnail().is_some()); assert!(m2.ingredients()[0].thumbnail().is_some()); } diff --git a/sdk/src/resource_store.rs b/sdk/src/resource_store.rs @@ -19,7 +19,7 @@ use std::{borrow::Cow, collections::HashMap}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::{Error, Result}; +use crate::{assertions::AssetType, claim::Claim, hashed_uri::HashedUri, Error, Result}; /// Function that is used by serde to determine whether or not we should serialize /// resources based on the `serialize_resources` flag. @@ -28,12 +28,68 @@ pub(crate) fn skip_serializing_resources(_: &ResourceStore) -> bool { !cfg!(feature = "serialize_thumbnails") || cfg!(test) } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[serde(untagged)] +pub enum UriOrResource { + ResourceRef(ResourceRef), + HashedUri(HashedUri), +} +impl UriOrResource { + pub fn to_hashed_uri( + &self, + resources: &ResourceStore, + claim: &mut Claim, + ) -> Result<UriOrResource> { + match self { + UriOrResource::ResourceRef(r) => { + let data = resources.get(&r.identifier)?; + let hash_uri = claim.add_databox(&r.format, data.to_vec(), None)?; + Ok(UriOrResource::HashedUri(hash_uri)) + } + UriOrResource::HashedUri(h) => Ok(UriOrResource::HashedUri(h.clone())), + } + } + + pub fn to_resource_ref( + &self, + resources: &mut ResourceStore, + claim: &Claim, + id: &str, + ) -> Result<UriOrResource> { + match self { + UriOrResource::ResourceRef(r) => Ok(UriOrResource::ResourceRef(r.clone())), + UriOrResource::HashedUri(h) => { + let data_box = claim.find_databox(&h.url()).ok_or(Error::MissingDataBox)?; + let resource_ref = + resources.add_with(id, &data_box.format, data_box.data.clone())?; + Ok(UriOrResource::ResourceRef(resource_ref)) + } + } + } +} + +impl From<ResourceRef> for UriOrResource { + fn from(r: ResourceRef) -> Self { + Self::ResourceRef(r) + } +} + +impl From<HashedUri> for UriOrResource { + fn from(h: HashedUri) -> Self { + Self::HashedUri(h) + } +} + /// 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))] + pub struct ResourceRef { pub format: String, pub identifier: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_types: Option<Vec<AssetType>>, } impl ResourceRef { @@ -41,6 +97,7 @@ impl ResourceRef { Self { format: format.into(), identifier: identifier.into(), + data_types: None, } } } @@ -84,6 +141,7 @@ impl ResourceStore { 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" => ".cp2a", _ => "", }; @@ -113,7 +171,7 @@ impl ResourceStore { } /// Adds a resource, using a given id value. - pub fn add<S, R>(&mut self, id: S, value: R) -> crate::Result<()> + pub fn add<S, R>(&mut self, id: S, value: R) -> crate::Result<&mut Self> where S: Into<String>, R: Into<Vec<u8>>, @@ -124,10 +182,10 @@ impl ResourceStore { std::fs::create_dir_all(path.parent().unwrap_or(Path::new("")))?; #[allow(clippy::expect_used)] std::fs::write(path, value.into())?; - return Ok(()); + return Ok(self); } self.resources.insert(id.into(), value.into()); - Ok(()) + Ok(self) } pub fn resources(&self) -> &HashMap<String, Vec<u8>> { diff --git a/sdk/src/store.rs b/sdk/src/store.rs @@ -26,7 +26,7 @@ use crate::{ }, assertions::{ labels::{self, CLAIM}, - DataHash, Ingredient, Relationship, + DataBox, DataHash, Ingredient, Relationship, }, asset_io::{ CAIRead, CAIReadWrite, HashBlockObjectType, HashObjectPositions, RemoteRefEmbedType, @@ -39,7 +39,7 @@ use crate::{ jumbf::{ self, boxes::*, - labels::{ASSERTIONS, CREDENTIALS, SIGNATURE}, + labels::{ASSERTIONS, CREDENTIALS, DATABOXES, SIGNATURE}, }, jumbf_io::{ get_assetio_handler, load_jumbf_from_memory, load_jumbf_from_stream, @@ -67,7 +67,7 @@ const MANIFEST_STORE_EXT: &str = "c2pa"; // file extension for external manifest /// A `Store` maintains a list of `Claim` structs. /// /// Typically, this list of `Claim`s represents all of the claims in an asset. -#[derive(Debug, PartialEq)] +#[derive(Debug)] pub struct Store { claims_map: HashMap<String, usize>, manifest_box_hash_cache: HashMap<String, Vec<u8>>, @@ -338,6 +338,27 @@ impl Store { } } + /// Returns a DataBox referenced by JUMBF URI if it exists. + /// + /// Relative paths will use the provenance claim to resolve the DataBox.d + pub fn get_data_box_from_uri_and_claim( + &self, + uri: &str, + target_claim_label: &str, + ) -> Option<&DataBox> { + match jumbf::labels::manifest_label_from_uri(uri) { + Some(label) => self.get_claim(&label), // use the manifest label from the thumbnail uri + None => self.get_claim(target_claim_label), // relative so use the target claim label + } + .and_then(|claim| { + claim + .databoxes() + .iter() + .find(|(h, _d)| h.url() == uri) + .map(|(_sh, data_box)| data_box) + }) + } + // Returns placeholder that will be searched for and replaced // with actual signature data. fn sign_claim_placeholder(claim: &Claim, min_reserve_size: usize) -> Vec<u8> { @@ -707,6 +728,31 @@ impl Store { cai_store.add_box(Box::new(vc_store)); // add the CAI assertion store to manifest } } + DATABOXES => { + // Add the data boxes + if !claim.databoxes().is_empty() { + let mut databoxes = CAIDataboxStore::new(); + + for (uri, db) in claim.databoxes() { + let db_cbor_bytes = + serde_cbor::to_vec(db).map_err(|_err| Error::AssertionEncoding)?; + + let (link, instance) = Claim::assertion_label_from_link(&uri.url()); + let label = Claim::label_with_instance(&link, instance); + + let mut db_cbor = CAICBORAssertionBox::new(&label); + db_cbor.add_cbor(db_cbor_bytes); + + if let Some(salt) = uri.salt() { + db_cbor.set_salt(salt.clone())?; + } + + databoxes.add_databox(Box::new(db_cbor)); + } + + cai_store.add_box(Box::new(databoxes)); // add claim to manifest + } + } _ => return Err(Error::ClaimInvalidContent), } } @@ -849,6 +895,7 @@ impl Store { CLAIM => box_order.push(CLAIM), SIGNATURE => box_order.push(SIGNATURE), CREDENTIALS => box_order.push(CREDENTIALS), + DATABOXES => box_order.push(DATABOXES), _ => { let log_item = log_item!("JUMBF", "unrecognized manifest box", "from_jumbf") @@ -1050,6 +1097,27 @@ impl Store { } } + // load databox store if available + if let Some(mi) = manifest_boxes.get(CAI_DATABOXES_STORE_UUID) { + let databox_store = mi.sbox; + let num_databoxes = databox_store.data_box_count(); + + for idx in 0..num_databoxes { + let db_box = databox_store + .data_box_as_superbox(idx) + .ok_or(Error::JumbfBoxNotFound)?; + let db_cbor = db_box + .data_box_as_cbor_box(0) + .ok_or(Error::JumbfBoxNotFound)?; + let db_desc_box = db_box.desc_box(); + let label = db_desc_box.label(); + + let salt = db_desc_box.get_salt(); + + claim.put_data_box(&label, db_cbor.cbor(), salt)?; + } + } + // save the hash of the loaded manifest for ingredient validation store.manifest_box_hash_cache.insert( claim.label().to_owned(), @@ -3125,6 +3193,26 @@ pub mod tests { #[test] #[cfg(feature = "file_io")] + fn test_get_data_boxes() { + // Create a new claim. + use crate::jumbf::labels::to_relative_uri; + let claim1 = create_test_claim().unwrap(); + + for (uri, db) in claim1.databoxes() { + // test full path + assert!(claim1.get_data_box(&uri.url()).is_some()); + + // test with relative path + let rel_path = to_relative_uri(&uri.url()); + assert!(claim1.get_data_box(&rel_path).is_some()); + + // test values + assert_eq!(db, claim1.get_data_box(&uri.url()).unwrap()); + } + } + + #[test] + #[cfg(feature = "file_io")] fn test_wav_jumbf_generation() { let ap = fixture_path("sample1.wav"); let temp_dir = tempdir().expect("temp dir"); @@ -3570,7 +3658,7 @@ pub mod tests { // test adding to actual image let ap = fixture_path("earth_apollo17.jpg"); let temp_dir = tempdir().expect("temp dir"); - let op = temp_dir_path(&temp_dir, "update_manifest.jpg"); + let op = temp_dir_path(&temp_dir, "earth_apollo17.jpg"); // get default store with default claim let mut store = create_test_store().unwrap(); @@ -3597,6 +3685,45 @@ pub mod tests { } } + #[test] + #[cfg(feature = "file_io")] + fn test_data_box_creation() { + use crate::utils::test::create_test_store; + + let signer = temp_signer(); + + // test adding to actual image + let ap = fixture_path("earth_apollo17.jpg"); + let temp_dir = tempdir().expect("temp dir"); + let op = temp_dir_path(&temp_dir, "earth_apollo17.jpg"); + + // get default store with default claim + let mut store = create_test_store().unwrap(); + + // save to output + store + .save_to_asset(ap.as_path(), signer.as_ref(), op.as_path()) + .unwrap(); + + // read back in + let restored_store = + Store::load_from_asset(op.as_path(), true, &mut OneShotStatusTracker::new()).unwrap(); + + let pc = restored_store.provenance_claim().unwrap(); + + let databoxes = pc.databoxes(); + + assert!(!databoxes.is_empty()); + + for (uri, db) in databoxes { + println!( + "URI: {}, data: {}", + uri.url(), + String::from_utf8_lossy(&db.data) + ); + } + } + /// copies a fixture, replaces some bytes and returns a validation report fn patch_and_report( fixture_name: &str, diff --git a/sdk/src/utils/cbor_types.rs b/sdk/src/utils/cbor_types.rs @@ -11,8 +11,10 @@ // specific language governing permissions and limitations under // each license. -use std::fmt; +use std::{fmt, ops::Deref}; +#[cfg(feature = "json_schema")] +use schemars::JsonSchema; use serde::{ de::{Deserialize, Deserializer}, ser::{Serialize, Serializer}, @@ -27,6 +29,7 @@ use serde_cbor::tags::Tagged; // // https://tools.ietf.org/html/rfc7049#section-2.4.1 #[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] pub struct DateT(pub String); impl Serialize for DateT { @@ -51,6 +54,14 @@ impl AsRef<str> for DateT { } } +impl Deref for DateT { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.as_str() + } +} + impl fmt::Display for DateT { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) diff --git a/sdk/src/utils/test.rs b/sdk/src/utils/test.rs @@ -60,6 +60,11 @@ pub const TEST_VC: &str = r#"{ pub fn create_test_claim() -> Result<Claim> { let mut claim = Claim::new("adobe unit test", Some("adobe")); + // add some data boxes + let _db_uri = claim.add_databox("text/plain", "this is a test".as_bytes().to_vec(), None)?; + let _db_uri_1 = + claim.add_databox("text/plain", "this is more text".as_bytes().to_vec(), None)?; + // add VC entry let _hu = claim.add_verifiable_credential(TEST_VC)?; diff --git a/sdk/tests/fixtures/manifest.json b/sdk/tests/fixtures/manifest.json @@ -0,0 +1,87 @@ +{ + "claim_generator": "test", + "claim_generator_info": [ + { + "name": "test", + "version": "1.0", + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + } + } + ], + "format" : "image/jpeg", + "thumbnail": { + "format": "image/jpeg", + "identifier": "IMG_0003.jpg" + }, + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "instanceId": "xmp.iid:7b57930e-2f23-47fc-affe-0400d70b738d", + "parameters": { + "description": "import" + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia", + "softwareAgent": { + "name": "TestApp", + "version": "1.0", + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + }, + "something": "else" + } + } + ], + "templates": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "TestApp", + "version": "1.0", + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + }, + "something": "else" + }, + "icon": { + "format": "image/svg+xml", + "identifier": "sample1.svg" + } + } + ] + } + } + ], + "ingredients": [{ + "title": "A.jpg", + "format": "image/jpeg", + "document_id": "xmp.did:813ee422-9736-4cdc-9be6-4e35ed8e41cb", + "relationship": "parentOf", + "thumbnail": { + "format": "image/png", + "identifier": "exp-test1.png" + } + }, + { + "title": "prompt", + "format": "text/plain", + "relationship": "inputTo", + "data": { + "format": "text/plain", + "identifier": "prompt.txt", + "data_types": [ + { + "type": "c2pa.types.generator.prompt" + } + ] + } + } + ] +} +\ No newline at end of file diff --git a/sdk/tests/fixtures/prompt.txt b/sdk/tests/fixtures/prompt.txt @@ -0,0 +1 @@ +pirate with bird on shoulder +\ No newline at end of file diff --git a/sdk/tests/integration.rs b/sdk/tests/integration.rs @@ -58,6 +58,7 @@ mod integration_1 { // add an action assertion stating that we imported this file actions = actions.add_action( Action::new(c2pa_action::EDITED) + .set_when("2015-06-26T16:43:23+0200") .set_parameter("name".to_owned(), "import")? .set_parameter("identifier".to_owned(), parent.instance_id().to_owned())?, ); @@ -102,4 +103,44 @@ mod integration_1 { } Ok(()) } + + #[test] + #[cfg(feature = "file_io")] + fn test_embed_json_manifest() -> Result<()> { + // set up parent and destination paths + let dir = tempdir()?; + let output_path = dir.path().join("test_file.jpg"); + + let mut fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + fixture_path.push("tests/fixtures"); + + let mut parent_path = fixture_path.clone(); + parent_path.push("earth_apollo17.jpg"); + let mut manifest_path = fixture_path.clone(); + manifest_path.push("manifest.json"); + + let json = std::fs::read_to_string(manifest_path)?; + + let mut manifest = Manifest::from_json(&json)?; + manifest.with_base_path(fixture_path.canonicalize()?)?; + + // sign and embed into the target file + let signer = get_temp_signer(); + manifest.embed(&parent_path, &output_path, &*signer)?; + + // read our new file with embedded manifest + let manifest_store = ManifestStore::from_file(&output_path)?; + + println!("{manifest_store}"); + // std::fs::copy(&output_path, "test_file.jpg")?; // for debugging to get copy of the file + + assert!(manifest_store.get_active().is_some()); + if let Some(manifest) = manifest_store.get_active() { + assert!(manifest.title().is_some()); + assert_eq!(manifest.ingredients().len(), 2); + } else { + panic!("no manifest in store"); + } + Ok(()) + } }