Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions sdk/src/assertions/labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,27 +224,6 @@ pub const ARCHIVE_TYPE_BUILDER: &str = "builder";
/// `archive:type` value for a single-ingredient working-store archive from [`Builder::write_ingredient_archive`](crate::Builder::write_ingredient_archive).
pub const ARCHIVE_TYPE_INGREDIENT: &str = "ingredient";

/// Typed representation of the `archive:type` field from an [`ARCHIVE_METADATA`] assertion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ArchiveType {
/// Full manifest working-store archive produced by [`crate::Builder::to_archive`].
Builder,
/// Single-ingredient archive produced by [`crate::Builder::write_ingredient_archive`].
Ingredient,
/// Unrecognized value — preserved for forward-compatible error reporting.
Unknown(String),
}

impl ArchiveType {
pub(crate) fn from_str(s: &str) -> Self {
match s {
ARCHIVE_TYPE_BUILDER => Self::Builder,
ARCHIVE_TYPE_INGREDIENT => Self::Ingredient,
other => Self::Unknown(other.to_string()),
}
}
}

/// Array of all hash labels because they have special treatment
pub const HASH_LABELS: [&str; 4] = [DATA_HASH, BOX_HASH, BMFF_HASH, COLLECTION_HASH];

Expand Down
317 changes: 287 additions & 30 deletions sdk/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ use crate::{
ManifestAssertionKind, Reader, Relationship, Signer,
};

/// Label for the `archive:type` field in working-store archive metadata.
const ARCHIVE_TYPE: &str = "archive:type";

/// Label for the `archive::ingredient_id` field in working-store archive metadata.
const ARCHIVE_INGREDIENT_ID: &str = "archive::ingredient_id";

/// The hash binding type that a [`Builder`] will use for embeddable signing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashType {
Expand Down Expand Up @@ -85,10 +91,19 @@ pub(crate) enum ArchiveKind {
}

impl ArchiveKind {
fn archive_type_str(&self) -> &'static str {
match self {
ArchiveKind::Builder => labels::ARCHIVE_TYPE_BUILDER,
ArchiveKind::Ingredient { .. } => labels::ARCHIVE_TYPE_INGREDIENT,
pub fn from_metadata(metadata: &Metadata) -> Option<Self> {
let archive_type = metadata.value.get(ARCHIVE_TYPE)?.as_str()?;
match archive_type {
labels::ARCHIVE_TYPE_BUILDER => Some(ArchiveKind::Builder),
labels::ARCHIVE_TYPE_INGREDIENT => {
let ingredient_id = metadata
.value
.get(ARCHIVE_INGREDIENT_ID)?
.as_str()?
.to_string();
Some(ArchiveKind::Ingredient { ingredient_id })
}
_ => None,
}
}
}
Expand Down Expand Up @@ -938,8 +953,7 @@ impl Builder {
let ingredient: Ingredient = Ingredient::from_json(&ingredient_json.into())?;

if format == "c2pa" || format == "application/c2pa" {
let reader = Reader::from_shared_context(&self.context).with_stream(format, stream)?;
let parent_ingredient = self.add_ingredient_from_reader(&reader)?;
let parent_ingredient = self.add_ingredient_from_archive(stream)?;
parent_ingredient.merge(&ingredient);
return self
.definition
Expand Down Expand Up @@ -3248,21 +3262,26 @@ impl Builder {
.await?
};

match reader.active_archive_type() {
Some(labels::ArchiveType::Ingredient) => {}
Some(other) => {
return Err(Error::BadParam(format!(
"expected an ingredient archive (archive:type {:?}), found {other:?}",
labels::ARCHIVE_TYPE_INGREDIENT
)));
}
None => {
return Err(Error::BadParam(format!(
"expected a C2PA ingredient archive (org.contentauth.archive.metadata with archive:type {:?}); use add_ingredient_from_reader or add_ingredient_from_stream for other stores",
labels::ARCHIVE_TYPE_INGREDIENT
)));
let ingredient_id = match reader.active_archive_kind() {
Some(ArchiveKind::Ingredient { ingredient_id }) => Some(ingredient_id),
// we should return an error here, but be tolerant in the transition if this has a parent.
Comment thread
gpeacock marked this conversation as resolved.
Some(ArchiveKind::Builder) if reader.active_manifest().is_some() => None,
_ => {
// early examples of ingredient archives may have been created without the correct archive metadata
// if it has an active manifest with a an ingredient store, allow it.
if let Some(manifest) = reader.active_manifest() {
if !manifest.ingredients().is_empty() {
None
} else {
return Err(Error::BadParam(
"expected an ingredient archive (org.contentauth.archive.metadata with archive:type ingredient)".to_string()));
}
} else {
return Err(Error::BadParam(
"expected an ingredient archive (org.contentauth.archive.metadata with archive:type ingredient)".to_string()));
}
}
}
};

if let Some(m) = reader.active_manifest() {
self.merge_resources_from_store(m.resources())?;
Expand All @@ -3271,7 +3290,10 @@ impl Builder {
}
}

let ingredient = reader.to_ingredient()?;
let mut ingredient = reader.to_ingredient()?;
if let Some(id) = ingredient_id {
ingredient.set_label(id);
}
self.add_ingredient(ingredient);
self.definition
.ingredients
Expand Down Expand Up @@ -3310,16 +3332,22 @@ impl Builder {
.to_claim()?,
};

let archive_type = kind.archive_type_str();
let json = json!(
{
"@context":
{
"archive": "https://contentauth.org/ns/archive#",
},
"archive:type": archive_type
let json = match &kind {
ArchiveKind::Ingredient { ingredient_id } if !ingredient_id.is_empty() => json!({
"@context": { "archive": "https://contentauth.org/ns/archive#" },
ARCHIVE_TYPE: labels::ARCHIVE_TYPE_INGREDIENT,
ARCHIVE_INGREDIENT_ID: ingredient_id
}),
ArchiveKind::Builder => json!({
"@context": { "archive": "https://contentauth.org/ns/archive#" },
ARCHIVE_TYPE: labels::ARCHIVE_TYPE_BUILDER
}),
_ => {
return Err(Error::BadParam(
"ingredient_id is required for ingredient archives".to_string(),
))
}
)
}
.to_string();

let archive_metadata = Metadata::new(labels::ARCHIVE_METADATA, &json)?;
Expand Down Expand Up @@ -8841,6 +8869,235 @@ mod tests {
.is_ok_and(|data| data.as_slice() == TEST_THUMBNAIL),
"thumbnail resource bytes should match the original TEST_THUMBNAIL"
);
assert_eq!(
builder2.definition.ingredients[0].label(),
Some("ingredient_1"),
"producer-supplied ingredient label should survive the archive round-trip via archive:ingredient_id"
);

Ok(())
}

#[test]
fn test_two_ingredient_archives_link_distinctly_via_actions() -> Result<()> {
// Create two ingredient archives with distinct ids ("ing-a", "ing-b"),
// load both into a builder, then sign.
let settings = Settings::new().with_value("builder.generate_c2pa_archive", true)?;
let context = Context::new().with_settings(settings)?.into_shared();

// Build two ingredient archives.
let make_archive = |id: &str, title: &str| -> Result<Vec<u8>> {
let mut builder = Builder::from_shared_context(&context)
.with_definition(r#"{"title": "Producer manifest"}"#)?;
let mut src = Cursor::new(TEST_IMAGE);
builder.add_ingredient_from_stream(
json!({
"title": title,
"format": "image/jpeg",
"relationship": "componentOf",
"label": id,
})
.to_string(),
"image/jpeg",
&mut src,
)?;
let mut archive = Cursor::new(Vec::new());
builder.write_ingredient_archive(id, &mut archive)?;
Ok(archive.into_inner())
};
let archive_a = make_archive("ing-a", "Ingredient A")?;
let archive_b = make_archive("ing-b", "Ingredient B")?;

// Builder: add both ingredient archives,
// then attach an action that links ing-b only.
let manifest_def = json!({
"claim_generator_info": [{ "name": "c2pa-test", "version": "1.0" }],
"title": "Two-ingredient signing manifest",
"assertions": [
{
"label": "c2pa.actions.v2",
"data": {
"actions": [
{
"action": "c2pa.placed",
"parameters": { "ingredientIds": ["ing-b"] }
}
]
}
}
]
});
let mut signing_builder =
Builder::from_shared_context(&context).with_definition(manifest_def.to_string())?;
signing_builder.add_ingredient_from_archive(&mut Cursor::new(archive_a))?;
signing_builder.add_ingredient_from_archive(&mut Cursor::new(archive_b))?;

assert_eq!(
signing_builder.definition.ingredients[0].label(),
Some("ing-a")
);
assert_eq!(
signing_builder.definition.ingredients[1].label(),
Some("ing-b")
);

// Sign...
let mut source = Cursor::new(TEST_IMAGE);
let mut dest = Cursor::new(Vec::new());
let signer = test_signer(SigningAlg::Ps256);
signing_builder.sign(signer.as_ref(), "image/jpeg", &mut source, &mut dest)?;

// Resolve the placed action's URL → ingredient.title and confirm it's "Ingredient B"
// (proves identity rather than relying on JUMBF slot ordering).
dest.rewind()?;
let reader = Reader::from_shared_context(&context).with_stream("image/jpeg", &mut dest)?;
let manifest = reader.active_manifest().expect("active manifest present");
let placed_url: &str = manifest
.assertions()
.iter()
.find(|a| a.label().contains("c2pa.actions"))
.and_then(|a| a.value().ok())
.and_then(|v: &serde_json::Value| {
v["actions"]
.as_array()?
.iter()
.find(|act| act["action"] == "c2pa.placed")?
.get("parameters")?
.get("ingredients")?
.as_array()?
.first()?
.get("url")?
.as_str()
})
.expect("placed action references an ingredient URL");

let target_label = placed_url.trim_start_matches("self#jumbf=c2pa.assertions/");
let linked_title = manifest
.ingredients()
.iter()
.find(|i| i.label() == Some(target_label))
.and_then(Ingredient::title)
.expect("ingredient with title present for resolved URL");
assert_eq!(
linked_title, "Ingredient B",
"placed action must link Ingredient B; got url {placed_url}"
);

Ok(())
}

#[test]
fn test_two_ingredient_archives_each_linked_componentof_placed() -> Result<()> {
// 2 ingredient archives are linked componentOf via two distinct c2pa.placed
// actions (one per ingredient).
let settings = Settings::new().with_value("builder.generate_c2pa_archive", true)?;
let context = Context::new().with_settings(settings)?.into_shared();

let make_archive = |id: &str, title: &str| -> Result<Vec<u8>> {
let mut builder = Builder::from_shared_context(&context)
.with_definition(r#"{"title": "Producer manifest"}"#)?;
let mut src = Cursor::new(TEST_IMAGE);
builder.add_ingredient_from_stream(
json!({
"title": title,
"format": "image/jpeg",
"relationship": "componentOf",
"label": id,
})
.to_string(),
"image/jpeg",
&mut src,
)?;
let mut archive = Cursor::new(Vec::new());
builder.write_ingredient_archive(id, &mut archive)?;
Ok(archive.into_inner())
};
let archive_a = make_archive("ing-a", "Ingredient A")?;
let archive_b = make_archive("ing-b", "Ingredient B")?;

let manifest_def = json!({
"claim_generator_info": [{ "name": "c2pa-test", "version": "1.0" }],
"title": "Two-ingredient componentOf placed manifest",
"assertions": [
{
"label": "c2pa.actions.v2",
"data": {
"actions": [
{
"action": "c2pa.placed",
"parameters": { "ingredientIds": ["ing-a"] }
},
{
"action": "c2pa.placed",
"parameters": { "ingredientIds": ["ing-b"] }
}
]
}
}
]
});
let mut signing_builder =
Builder::from_shared_context(&context).with_definition(manifest_def.to_string())?;
signing_builder.add_ingredient_from_archive(&mut Cursor::new(archive_a))?;
signing_builder.add_ingredient_from_archive(&mut Cursor::new(archive_b))?;

let mut source = Cursor::new(TEST_IMAGE);
let mut dest = Cursor::new(Vec::new());
let signer = test_signer(SigningAlg::Ps256);
signing_builder.sign(signer.as_ref(), "image/jpeg", &mut source, &mut dest)?;

dest.rewind()?;
let reader = Reader::from_shared_context(&context).with_stream("image/jpeg", &mut dest)?;
print!("reader JSON: {}", reader.json());
let manifest = reader.active_manifest().expect("active manifest present");
let actions_value: serde_json::Value = manifest
.assertions()
.iter()
.find(|a| a.label().contains("c2pa.actions"))
.and_then(|a| a.value().ok().cloned())
.expect("c2pa.actions assertion present");

// Verify ingredients and actions were properly linked
let placed_urls: Vec<&str> = actions_value["actions"]
.as_array()
.expect("actions array")
.iter()
.filter(|act| act["action"] == "c2pa.placed")
.filter_map(|act| {
act.get("parameters")?
.get("ingredients")?
.as_array()?
.first()?
.get("url")?
.as_str()
})
.collect();
assert_eq!(
placed_urls.len(),
2,
"expected two placed actions each referencing one ingredient; got {placed_urls:?}"
);
let title_for_url = |url: &str| -> &str {
let target_label = url.trim_start_matches("self#jumbf=c2pa.assertions/");
manifest
.ingredients()
.iter()
.find(|i| i.label() == Some(target_label))
.and_then(Ingredient::title)
.expect("ingredient with title present for resolved URL")
};
assert_eq!(
title_for_url(placed_urls[0]),
"Ingredient A",
"first placed action must link Ingredient A; got url {}",
placed_urls[0]
);
assert_eq!(
title_for_url(placed_urls[1]),
"Ingredient B",
"second placed action must link Ingredient B; got url {}",
placed_urls[1]
);

Ok(())
}
Expand Down
Loading