Skip to content

Commit b4c640f

Browse files
authored
feat: Enables generating created assertions and getting the created state (#1432)
* chore: fix build issues in main * feat: Add support for created vs gathered assertions Replace AssertionDefinition with ManifestAssertion. * chore: fix example for passing ingredientIds * chore: Actions Base should use struct LABEL * feat: Add assertion label parsing code for base, version and instance * chore: revert to AssertionDefinition Adds created to ManifestAssertion Adds AssertionKind kind() and content_type() to AssertionBase with defaults. Adds AssertionBinary Trait (not used yet) * fix: make tests repairs * chore: cleanup * feat: Settings for intent and created add_action will now insert created and opened at the top of the actions (fixes a duplicate action bug) Updates v2api example to add an opened action * feat: make labels::parse_label public and document it. * feat: make labels::parse_label() public and documented. * feat: claim.add_assertion automation. the add_assertion method will now determine when salting and created/vs gathered are needed. A list of created assertion names can be provided in settings, and you can still call add_created_assertion when needed. All assertions are considered gathered by default except for hard bindings. All redactable assertions are salted. Adding a custom salt is now private to Claim but can be made public if needed (it was never used) add_assertion_with_salt and add_gathered_assertion_with salt have been removed. * chore: add unit tests * chore: review feedback renamed settings.builder.created_assertions to created_assertion_labels salt all assertions removes unused AssertionBinary code. * chore: build repairs * chore: review feedback changed v2api.rs to api.rs removed unused experimental assertion static apis * chore: remove println
1 parent e4bc1ea commit b4c640f

File tree

18 files changed

+520
-274
lines changed

18 files changed

+520
-274
lines changed

sdk/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ required-features = ["file_io"]
7272
name = "v2show"
7373

7474
[[example]]
75-
name = "v2api"
75+
name = "api"
7676

7777
[[example]]
7878
name = "fragmented_bmff"

sdk/examples/v2api.rs renamed to sdk/examples/api.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ fn manifest_def(title: &str, format: &str) -> String {
6767
}).to_string()
6868
}
6969

70-
/// This example demonstrates how to use the new v2 API to create a manifest store
70+
/// This example demonstrates how to use the API to create a manifest store
7171
/// It uses only streaming apis, showing how to avoid file i/o
7272
/// This example uses the `ed25519` signing algorithm
7373
fn main() -> Result<()> {
@@ -94,13 +94,21 @@ fn main() -> Result<()> {
9494
builder.add_ingredient_from_stream(
9595
json!({
9696
"title": parent_name,
97-
"relationship": "parentOf"
97+
"relationship": "parentOf",
98+
"label": "parent_label", // use a label to tie this ingredient to an action
9899
})
99100
.to_string(),
100101
format,
101102
&mut source,
102103
)?;
103104

105+
builder.add_action(json!({
106+
"action": "c2pa.opened",
107+
"parameters": {
108+
"ingredientIds": ["parent_label"], // the ingredient title to reference the ingredient
109+
}
110+
}))?;
111+
104112
let thumb_uri = builder
105113
.definition
106114
.thumbnail

sdk/examples/client/client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ pub fn main() -> Result<()> {
116116
// create an action assertion stating that we imported this file
117117
let actions = Actions::new().add_action(
118118
Action::new(c2pa_action::OPENED)
119-
.set_parameter("ingredients", [parent.instance_id().to_owned()])?,
119+
.set_parameter("ingredientIds", [parent.instance_id().to_owned()])?,
120120
);
121121

122122
// build a creative work assertion

sdk/src/assertion.rs

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -103,21 +103,22 @@ pub trait AssertionBase
103103
where
104104
Self: Sized,
105105
{
106+
/// The label for this assertion (reverse domain format)
106107
const LABEL: &'static str = "unknown";
107108

109+
/// The version for this assertion (if any) Defaults to None/1
108110
const VERSION: Option<usize> = None;
109111

110112
/// Returns a label for this assertion.
111113
fn label(&self) -> &str {
112114
Self::LABEL
113115
}
114116

115-
/// Returns a version for this assertion.
116117
fn version(&self) -> Option<usize> {
117118
Self::VERSION
118119
}
119120

120-
/// Returns an Assertion upon success or Error otherwise.
121+
/// Convert this instance to an Assertion
121122
fn to_assertion(&self) -> Result<Assertion>;
122123

123124
/// Returns Self or AssertionDecode Result from an assertion
@@ -130,7 +131,7 @@ pub trait AssertionCbor: Serialize + DeserializeOwned + AssertionBase {
130131
let data = AssertionData::Cbor(
131132
serde_cbor::to_vec(self).map_err(|err| Error::AssertionEncoding(err.to_string()))?,
132133
);
133-
Ok(Assertion::new(self.label(), self.version(), data))
134+
Ok(Assertion::new(self.label(), self.version(), data).set_content_type("application/cbor"))
134135
}
135136

136137
fn from_cbor_assertion(assertion: &Assertion) -> Result<Self> {
@@ -263,6 +264,10 @@ impl Assertion {
263264
self.version.unwrap_or(1)
264265
}
265266

267+
pub fn version(&self) -> usize {
268+
self.version.unwrap_or(1)
269+
}
270+
266271
// pub fn check_version(&self, max_version: usize) -> AssertionDecodeResult<()> {
267272
// match self.version {
268273
// Some(version) if version > max_version => Err(AssertionDecodeError {
@@ -284,6 +289,7 @@ impl Assertion {
284289
}
285290

286291
/// return mimetype for the the data enclosed in the Assertion
292+
// Todo: deprecate this in favor of content_type()
287293
pub(crate) fn mime_type(&self) -> String {
288294
self.content_type.clone()
289295
}
@@ -383,7 +389,7 @@ impl Assertion {
383389

384390
Self {
385391
label,
386-
version,
392+
version: if version == 1 { None } else { Some(version) },
387393
data,
388394
content_type: content_type.to_owned(),
389395
}
@@ -442,7 +448,7 @@ impl Assertion {
442448
let json = String::from_utf8(binary_data.to_vec()).map_err(|_| AssertionDecodeError {
443449
label: label.to_string(),
444450
version: None, // TODO: Can we get this info?
445-
content_type: "json".to_string(),
451+
content_type: "application/json".to_string(),
446452
source: AssertionDecodeErrorCause::BinaryDataNotUtf8,
447453
})?;
448454

@@ -458,18 +464,17 @@ impl Assertion {
458464
&self,
459465
desired_version: usize,
460466
) -> AssertionDecodeResult<()> {
461-
if let Some(base_version) = labels::version(&self.label) {
462-
if desired_version > base_version {
463-
return Err(AssertionDecodeError {
464-
label: self.label.clone(),
465-
version: self.version,
466-
content_type: self.content_type.clone(),
467-
source: AssertionDecodeErrorCause::AssertionTooNew {
468-
max: desired_version,
469-
found: base_version,
470-
},
471-
});
472-
}
467+
let base_version = labels::version(&self.label);
468+
if desired_version > base_version {
469+
return Err(AssertionDecodeError {
470+
label: self.label.clone(),
471+
version: self.version,
472+
content_type: self.content_type.clone(),
473+
source: AssertionDecodeErrorCause::AssertionTooNew {
474+
max: desired_version,
475+
found: base_version,
476+
},
477+
});
473478
}
474479

475480
Ok(())

sdk/src/assertions/actions.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -833,8 +833,15 @@ impl Actions {
833833
}
834834

835835
/// Adds an [`Action`] to this assertion's list of actions.
836+
/// OPENED and CREATED actions are inserted at the beginning of the list.
837+
/// as required by the c2pa specification.
838+
/// Note, this does not check for duplicates since it does not return errors.
836839
pub fn add_action(mut self, action: Action) -> Self {
837-
self.actions.push(action);
840+
if action.action() == c2pa_action::OPENED || action.action() == c2pa_action::CREATED {
841+
self.actions.insert(0, action);
842+
} else {
843+
self.actions.push(action);
844+
}
838845
self
839846
}
840847

@@ -862,7 +869,7 @@ impl Actions {
862869
impl AssertionCbor for Actions {}
863870

864871
impl AssertionBase for Actions {
865-
const LABEL: &'static str = labels::ACTIONS;
872+
const LABEL: &'static str = Self::LABEL;
866873
const VERSION: Option<usize> = Some(ASSERTION_CREATION_VERSION);
867874

868875
fn version(&self) -> Option<usize> {

sdk/src/assertions/labels.rs

Lines changed: 104 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,13 @@ pub const METADATA: &str = "c2pa.metadata";
219219
/// [CAWG metadata assertion]: https://cawg.io/metadata/
220220
pub const CAWG_METADATA: &str = "cawg.metadata";
221221

222+
/// Array of all hash labels because they have special treatment
223+
pub const HASH_LABELS: [&str; 4] = [DATA_HASH, BOX_HASH, BMFF_HASH, COLLECTION_HASH];
224+
225+
/// Array of all non-redactable labels
226+
pub const NON_REDACTABLE_LABELS: [&str; 5] =
227+
[ACTIONS, DATA_HASH, BOX_HASH, BMFF_HASH, COLLECTION_HASH];
228+
222229
/// Must have a label that ends in '.metadata' and is preceded by an entity-specific namespace.
223230
/// For example, a 'com.litware.metadata' assertion would be valid.
224231
pub static METADATA_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
@@ -229,14 +236,79 @@ pub static METADATA_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
229236
}
230237
});
231238

232-
/// Return the version suffix from an assertion label if it exists.
239+
/// Parse a label into its components
240+
///
241+
/// This function takes a label string and parses it into its base label,
242+
/// version number, and instance number. The base label is the part of the
243+
/// label without any version or instance suffixes. The version number is
244+
/// extracted from a suffix of the form `.v{number}`, defaulting to 1 if
245+
/// not present. The instance number is extracted from a suffix of the form
246+
/// `__{number}`, defaulting to 0 if not present.
247+
///
248+
/// ABNF grammar for labels:
249+
/// ```abnf
250+
/// namespaced-label = qualified-namespace label [version] [instance]
251+
/// qualified-namespace = "c2pa" / entity
252+
/// entity = entity-component *( "." entity-component )
253+
/// entity-component = 1( DIGIT / ALPHA ) *( DIGIT / ALPHA / "-" / "_" )
254+
/// label = 1*( "." label-component )
255+
/// label-component = 1( DIGIT / ALPHA ) *( DIGIT / ALPHA / "-" / "_" )
256+
/// version = ".v" 1*DIGIT
257+
/// instance = "__" 1*DIGIT
258+
/// ```
259+
pub fn parse_label(label: &str) -> (&str, usize, usize) {
260+
// First, extract instance if present
261+
let (without_instance, instance) = if let Some(pos) = label.rfind("__") {
262+
let instance_str = &label[pos + 2..];
263+
let instance = instance_str.parse::<usize>().unwrap_or(0);
264+
(&label[..pos], instance)
265+
} else {
266+
(label, 0)
267+
};
268+
269+
// Then, extract version if present
270+
#[allow(clippy::unwrap_used)]
271+
static VERSION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^v\d+$").unwrap());
272+
let components: Vec<&str> = without_instance.split('.').collect();
273+
if let Some(last) = components.last() {
274+
if VERSION_RE.is_match(last) {
275+
if let Ok(version) = last[1..].parse::<usize>() {
276+
let base_end = without_instance.len() - last.len() - 1;
277+
return (&without_instance[..base_end], version, instance);
278+
}
279+
}
280+
}
281+
282+
(without_instance, 1, instance)
283+
}
284+
285+
/// Extract the base label without version or instance suffixes
286+
///
287+
/// This function removes both the version suffix (`.v{number}`) and
288+
/// instance suffix (`__{number}`) from a label, returning just the base.
289+
///
290+
/// # Examples
291+
/// ```
292+
/// use c2pa::assertions::labels;
293+
///
294+
/// assert_eq!(labels::base("c2pa.ingredient"), "c2pa.ingredient");
295+
/// assert_eq!(labels::base("c2pa.ingredient.v3"), "c2pa.ingredient");
296+
/// assert_eq!(labels::base("c2pa.ingredient__2"), "c2pa.ingredient");
297+
/// assert_eq!(labels::base("c2pa.ingredient.v3__2"), "c2pa.ingredient");
298+
/// assert_eq!(labels::base("c2pa.actions__1"), "c2pa.actions");
299+
/// ```
300+
pub fn base(label: &str) -> &str {
301+
parse_label(label).0
302+
}
303+
304+
/// Extract version from a label
233305
///
234306
/// When an assertion's schema is changed in a backwards-compatible manner,
235307
/// the label would consist of an incremented version number, for example
236308
/// moving from `c2pa.ingredient` to `c2pa.ingredient.v2`.
237309
///
238-
/// If such a suffix exists (`.v(integer)`), return that; otherwise,
239-
/// return `None`.
310+
/// Returns the version number, or 1 if no version suffix is present
311+
/// (since version 1 is the default and never explicitly included).
240312
///
241313
/// See <https://c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_versioning>.
242314
///
@@ -245,39 +317,38 @@ pub static METADATA_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
245317
/// ```
246318
/// use c2pa::assertions::labels;
247319
///
248-
/// assert_eq!(labels::version("c2pa.ingredient"), None);
249-
/// assert_eq!(labels::version("c2pa.ingredient.v2"), Some(2));
250-
/// assert_eq!(labels::version("c2pa.ingredient.V2"), None);
251-
/// assert_eq!(labels::version("c2pa.ingredient.x2"), None);
252-
/// assert_eq!(labels::version("c2pa.ingredient.v-2"), None);
320+
/// assert_eq!(labels::version("c2pa.ingredient"), 1);
321+
/// assert_eq!(labels::version("c2pa.ingredient.v2"), 2);
322+
/// assert_eq!(labels::version("c2pa.ingredient.v3__2"), 3);
323+
/// assert_eq!(labels::version("c2pa.ingredient.V2"), 1);
324+
/// assert_eq!(labels::version("c2pa.ingredient.x2"), 1);
325+
/// assert_eq!(labels::version("c2pa.ingredient.v-2"), 1);
253326
/// ```
254-
pub fn version(label: &str) -> Option<usize> {
255-
let components: Vec<&str> = label.split('.').collect();
256-
if let Some(last) = components.last() {
257-
if last.len() > 1 {
258-
let (ver, ver_inst_str) = last.split_at(1);
259-
if ver == "v" {
260-
if let Ok(ver) = ver_inst_str.parse::<usize>() {
261-
return Some(ver);
262-
}
263-
}
264-
}
265-
}
266-
267-
None
327+
pub fn version(label: &str) -> usize {
328+
parse_label(label).1
268329
}
269330

270-
/// Set the version of a label.
271-
/// If the version is 1, the original label is returned.
272-
/// Otherwise, the label is suffixed with the version number.
273-
/// This expects the label to not already have a version suffix.
274-
pub fn set_version(base_label: &str, version: usize) -> String {
275-
if version == 1 {
276-
// c2pa does not include v1 labels
277-
base_label.to_string()
278-
} else {
279-
format!("{base_label}.v{version}")
280-
}
331+
/// Extract the instance number from a label (return 0 if none)
332+
///
333+
/// This function looks for a double underscore followed by a number
334+
/// in the label and returns that number as the instance. If no such
335+
/// pattern is found, it returns zero.
336+
/// "__0" is default and never part of a label.
337+
/// Invalid instances are also treated as zero.
338+
///
339+
/// # Examples
340+
/// ```
341+
/// use c2pa::assertions::labels;
342+
///
343+
/// assert_eq!(labels::instance("c2pa.ingredient"), 0);
344+
/// assert_eq!(labels::instance("c2pa.actions__1"), 1);
345+
/// assert_eq!(labels::instance("c2pa.ingredient.v3__2"), 2);
346+
/// assert_eq!(labels::instance("c2pa.ingredient__2"), 2);
347+
/// assert_eq!(labels::instance("c2pa.ingredient__x"), 0);
348+
/// assert_eq!(labels::instance("c2pa.ingredient__"), 0);
349+
/// ```
350+
pub fn instance(label: &str) -> usize {
351+
parse_label(label).2
281352
}
282353

283354
/// Given a thumbnail label prefix such as `CLAIM_THUMBNAIL` and a file

0 commit comments

Comments
 (0)