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:
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(())
+ }
}