use crate::cc_attestation;
use crate::error::{Error, Result};
use crate::hash;
use crate::in_toto;
use crate::manifest::config::ManifestCreationConfig;
use crate::manifest::utils::{
determine_dataset_type, determine_format, determine_model_type, determine_software_type,
};
use crate::signing::signable::Signable;
use crate::storage::traits::{ArtifactLocation, StorageBackend};
use atlas_c2pa_lib::assertion::{
Action, ActionAssertion, Assertion, Author, CreativeWorkAssertion, CustomAssertion,
};
use atlas_c2pa_lib::asset_type::AssetType;
use atlas_c2pa_lib::claim::ClaimV2;
use atlas_c2pa_lib::cose::HashAlgorithm;
use atlas_c2pa_lib::cross_reference::CrossReference;
use atlas_c2pa_lib::datetime_wrapper::OffsetDateTimeWrapper;
use atlas_c2pa_lib::ingredient::{Ingredient, IngredientData};
use atlas_c2pa_lib::manifest::Manifest;
use serde_json::{to_string, to_string_pretty};
use std::path::{Path, PathBuf};
use tdx_workload_attestation::get_platform_name;
use time::OffsetDateTime;
use uuid::Uuid;
const CLAIM_GENERATOR: &str = "atlas-cli:0.2.0";
pub enum AssetKind {
Model,
Dataset,
Software,
Evaluation,
}
fn generate_c2pa_assertions(
config: &ManifestCreationConfig,
asset_kind: AssetKind,
) -> Result<Vec<Assertion>> {
let (creative_type, digital_source_type) = match asset_kind {
AssetKind::Model => (
"Model".to_string(),
"http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia".to_string(),
),
AssetKind::Dataset => (
"Dataset".to_string(),
"http://cv.iptc.org/newscodes/digitalsourcetype/dataset".to_string(),
),
AssetKind::Software => (
"Software".to_string(),
"http://cv.iptc.org/newscodes/digitalsourcetype/software".to_string(),
),
AssetKind::Evaluation => (
"EvaluationResult".to_string(),
"http://cv.iptc.org/newscodes/digitalsourcetype/evaluationResult".to_string(),
),
};
let mut assertions = vec![
Assertion::CreativeWork(CreativeWorkAssertion {
context: "http://schema.org/".to_string(),
creative_type,
author: vec![
Author {
author_type: "Organization".to_string(),
name: config
.author_org
.clone()
.unwrap_or_else(|| "Organization".to_string()),
},
Author {
author_type: "Person".to_string(),
name: config
.author_name
.clone()
.unwrap_or_else(|| "Unknown".to_string()),
},
],
}),
Assertion::Action(ActionAssertion {
actions: vec![Action {
action: match asset_kind {
AssetKind::Evaluation => "c2pa.evaluation".to_string(),
_ => "c2pa.created".to_string(),
},
software_agent: Some(CLAIM_GENERATOR.to_string()),
parameters: Some(match asset_kind {
AssetKind::Evaluation => {
let mut params = serde_json::json!({
"name": config.name,
"description": config.description,
"author": {
"organization": config.author_org,
"name": config.author_name
}
});
if let Some(config_params) = &config.custom_fields {
if let Some(eval_params) = config_params.get("evaluation") {
if let Some(obj) = params.as_object_mut() {
obj.insert(
"model_id".to_string(),
eval_params
.get("model_id")
.cloned()
.unwrap_or(serde_json::Value::Null),
);
obj.insert(
"dataset_id".to_string(),
eval_params
.get("dataset_id")
.cloned()
.unwrap_or(serde_json::Value::Null),
);
obj.insert(
"metrics".to_string(),
eval_params
.get("metrics")
.cloned()
.unwrap_or(serde_json::Value::Null),
);
}
}
}
params
}
AssetKind::Software => {
let mut params = serde_json::json!({
"name": config.name,
"description": config.description,
"author": {
"organization": config.author_org,
"name": config.author_name
}
});
if let Some(software_type) = &config.software_type {
params.as_object_mut().unwrap().insert(
"software_type".to_string(),
serde_json::Value::String(software_type.clone()),
);
}
if let Some(version) = &config.version {
params.as_object_mut().unwrap().insert(
"version".to_string(),
serde_json::Value::String(version.clone()),
);
}
params
}
_ => serde_json::json!({}),
}),
digital_source_type: Some(digital_source_type),
instance_id: None,
}],
}),
];
if config.with_cc {
let cc_assertion = get_cc_attestation_assertion().unwrap();
assertions.push(Assertion::CustomAssertion(cc_assertion));
}
Ok(assertions)
}
fn generate_c2pa_claim(config: &ManifestCreationConfig, asset_kind: AssetKind) -> Result<ClaimV2> {
let mut ingredients = Vec::new();
for (path, ingredient_name) in config.paths.iter().zip(config.ingredient_names.iter()) {
let format = determine_format(path)?;
let asset_type = match asset_kind {
AssetKind::Model => determine_model_type(path)?,
AssetKind::Dataset => determine_dataset_type(path)?,
AssetKind::Software => determine_software_type(path)?,
AssetKind::Evaluation => AssetType::Dataset, };
let ingredient = create_ingredient_from_path_with_algorithm(
path,
ingredient_name,
asset_type,
format,
&config.hash_alg,
)?;
ingredients.push(ingredient);
}
ingredients.sort_by_key(|ingredient| ingredient.title.to_lowercase());
let assertions = generate_c2pa_assertions(config, asset_kind)?;
Ok(ClaimV2 {
instance_id: format!("urn:c2pa:{}", Uuid::new_v4()),
ingredients: ingredients.clone(),
created_assertions: assertions,
claim_generator_info: CLAIM_GENERATOR.to_string(),
signature: None,
created_at: OffsetDateTimeWrapper(OffsetDateTime::now_utc()),
})
}
pub fn create_manifest(config: ManifestCreationConfig, asset_kind: AssetKind) -> Result<()> {
let claim = generate_c2pa_claim(&config, asset_kind)?;
let mut manifest = Manifest {
claim_generator: CLAIM_GENERATOR.to_string(),
title: config.name.clone(),
instance_id: format!("urn:c2pa:{}", Uuid::new_v4()),
claim: claim.clone(),
ingredients: vec![],
created_at: OffsetDateTimeWrapper(OffsetDateTime::now_utc()),
cross_references: vec![],
claim_v2: Some(claim),
is_active: true,
};
if let Some(key_file) = &config.key_path {
manifest.sign(key_file.to_path_buf(), config.hash_alg)?;
}
if let Some(manifest_ids) = &config.linked_manifests {
if let Some(storage_backend) = &config.storage {
for linked_id in manifest_ids {
match storage_backend.retrieve_manifest(linked_id) {
Ok(linked_manifest) => {
let linked_json = serde_json::to_string(&linked_manifest)
.map_err(|e| Error::Serialization(e.to_string()))?;
let linked_hash = hash::calculate_hash(linked_json.as_bytes());
let cross_ref = CrossReference {
manifest_url: linked_id.clone(),
manifest_hash: linked_hash,
media_type: Some("application/json".to_string()),
};
manifest.cross_references.push(cross_ref);
println!("Added link to manifest: {linked_id}");
}
Err(e) => {
println!("Warning: Could not link to manifest {linked_id}: {e}");
}
}
}
} else {
println!("Warning: Cannot link manifests without a storage backend");
}
}
if config.print || config.storage.is_none() {
match config.output_encoding.to_lowercase().as_str() {
"json" => {
let manifest_json =
to_string_pretty(&manifest).map_err(|e| Error::Serialization(e.to_string()))?;
println!("{manifest_json}");
}
"cbor" => {
let manifest_cbor = serde_cbor::to_vec(&manifest)
.map_err(|e| Error::Serialization(e.to_string()))?;
println!("{}", hex::encode(&manifest_cbor));
}
_ => {
return Err(Error::Validation(format!(
"Invalid output encoding '{}'. Valid options are: json, cbor",
config.output_encoding
)));
}
}
}
if let Some(storage) = &config.storage {
if !config.print {
let id = storage.store_manifest(&manifest)?;
println!("Manifest stored successfully with ID: {id}");
}
}
Ok(())
}
pub fn create_oms_manifest(config: ManifestCreationConfig) -> Result<()> {
let claim = generate_c2pa_claim(&config, AssetKind::Model)?;
let mut manifest = Manifest {
claim_generator: "".to_string(),
title: "".to_string(),
instance_id: format!("urn:c2pa:{}", Uuid::new_v4()),
claim: claim.clone(),
ingredients: vec![],
created_at: OffsetDateTimeWrapper(OffsetDateTime::now_utc()),
cross_references: vec![],
claim_v2: None,
is_active: true,
};
if let Some(manifest_ids) = &config.linked_manifests {
if let Some(storage_backend) = &config.storage {
for linked_id in manifest_ids {
match storage_backend.retrieve_manifest(linked_id) {
Ok(linked_manifest) => {
let linked_json = serde_json::to_string(&linked_manifest)
.map_err(|e| Error::Serialization(e.to_string()))?;
let linked_hash = hash::calculate_hash(linked_json.as_bytes());
let cross_ref = CrossReference {
manifest_url: linked_id.clone(),
manifest_hash: linked_hash,
media_type: Some("application/json".to_string()),
};
manifest.cross_references.push(cross_ref);
println!("Added link to manifest: {linked_id}");
}
Err(e) => {
println!("Warning: Could not link to manifest {linked_id}: {e}");
}
}
}
} else {
println!("Warning: Cannot link manifests without a storage backend");
}
}
let manifest_json = to_string(&manifest).map_err(|e| Error::Serialization(e.to_string()))?;
let manifest_proto = in_toto::json_to_struct_proto(&manifest_json)?;
let subject_hash = generate_oms_subject_hash(&manifest, &config.hash_alg)?;
let subject = in_toto::make_minimal_resource_descriptor(
&config.name,
hash::algorithm_to_string(&config.hash_alg),
&subject_hash,
);
let key_path = config
.key_path
.ok_or_else(|| Error::Validation("OMS format requires a signing key".to_string()))?;
let envelope = in_toto::generate_signed_statement_v1(
&[subject],
"https://spec.c2pa.org/specifications/specifications/2.2",
&manifest_proto,
key_path.to_path_buf(),
config.hash_alg,
)?;
if config.print || config.storage.is_none() {
match config.output_encoding.to_lowercase().as_str() {
"json" => {
let envelope_json =
to_string_pretty(&envelope).map_err(|e| Error::Serialization(e.to_string()))?;
println!("{envelope_json}");
}
"cbor" => {
let envelope_cbor = serde_cbor::to_vec(&envelope)
.map_err(|e| Error::Serialization(e.to_string()))?;
println!("{}", hex::encode(&envelope_cbor));
}
_ => {
return Err(Error::Validation(format!(
"Invalid output encoding '{}'. Valid options are: json, cbor",
config.output_encoding
)));
}
}
}
if let Some(storage) = &config.storage {
if !config.print {
let id = storage.store_manifest(&manifest)?;
println!("Manifest stored successfully with ID: {id}");
}
}
Ok(())
}
pub fn list_manifests(storage: &dyn StorageBackend, asset_kind: Option<AssetKind>) -> Result<()> {
let manifests = storage.list_manifests()?;
let filtered_manifests = if let Some(kind) = asset_kind {
manifests
.into_iter()
.filter(|m| match kind {
AssetKind::Model => {
matches!(m.manifest_type, crate::storage::traits::ManifestType::Model)
}
AssetKind::Dataset => matches!(
m.manifest_type,
crate::storage::traits::ManifestType::Dataset
),
AssetKind::Software => matches!(
m.manifest_type,
crate::storage::traits::ManifestType::Software
),
AssetKind::Evaluation => {
m.name.contains("Evaluation") || m.name.contains("evaluation")
}
})
.collect::<Vec<_>>()
} else {
manifests
};
for metadata in filtered_manifests {
println!(
"Manifest: {} (ID: {}, Type: {:?}, Created: {})",
metadata.name, metadata.id, metadata.manifest_type, metadata.created_at
);
}
Ok(())
}
pub fn verify_manifest(id: &str, storage: &dyn StorageBackend) -> Result<()> {
let manifest = storage.retrieve_manifest(id)?;
atlas_c2pa_lib::manifest::validate_manifest(&manifest)
.map_err(|e| crate::error::Error::Validation(e.to_string()))?;
println!("Verifying manifest with ID: {id}");
for ingredient in &manifest.ingredients {
println!("Verifying ingredient: {}", ingredient.title);
if ingredient.data.url.starts_with("file://") {
let path = PathBuf::from(ingredient.data.url.trim_start_matches("file://"));
let location = ArtifactLocation {
url: ingredient.data.url.clone(),
file_path: Some(path),
hash: ingredient.data.hash.clone(),
};
match location.verify() {
Ok(true) => {
println!(
"✓ Successfully verified hash for component: {}",
ingredient.title
);
}
Ok(false) => {
return Err(Error::Validation(format!(
"Hash verification failed for component: {}. The file may have been modified.",
ingredient.title
)));
}
Err(e) => {
return Err(Error::Validation(format!(
"Error verifying component {}: {}. The file may be missing or inaccessible.",
ingredient.title, e
)));
}
}
} else {
match hash::calculate_file_hash(PathBuf::from(&ingredient.data.url)) {
Ok(calculated_hash) => {
if calculated_hash != ingredient.data.hash {
return Err(Error::Validation(format!(
"Hash mismatch for ingredient: {}",
ingredient.title
)));
}
println!(
"✓ Successfully verified hash for component: {}",
ingredient.title
);
}
Err(_) => {
println!(
"âš Warning: Component {} does not use file:// URL scheme and could not be verified directly",
ingredient.title
);
}
}
}
}
if !manifest.cross_references.is_empty() {
println!("Verifying cross-references...");
for cross_ref in &manifest.cross_references {
let linked_manifest = storage.retrieve_manifest(&cross_ref.manifest_url)?;
let manifest_json = serde_json::to_string(&linked_manifest)
.map_err(|e| Error::Serialization(e.to_string()))?;
let algorithm = hash::detect_hash_algorithm(&cross_ref.manifest_hash);
let calculated_hash =
hash::calculate_hash_with_algorithm(manifest_json.as_bytes(), &algorithm);
if calculated_hash != cross_ref.manifest_hash {
return Err(Error::Validation(format!(
"Cross-reference verification failed for linked manifest: {}. Hash mismatch: stored={}, calculated={}",
cross_ref.manifest_url, cross_ref.manifest_hash, calculated_hash
)));
}
println!(
"✓ Verified cross-reference to manifest: {}",
cross_ref.manifest_url
);
}
}
verify_asset_specific_requirements(&manifest)?;
println!("✓ Manifest verification successful");
Ok(())
}
fn verify_asset_specific_requirements(manifest: &Manifest) -> Result<()> {
let is_dataset = is_dataset_manifest(manifest);
let is_model = is_model_manifest(manifest);
let is_software = is_software_manifest(manifest);
let is_evaluation = is_evaluation_manifest(manifest);
if !is_evaluation && manifest.ingredients.is_empty() {
return Err(Error::Validation(
"Manifest must contain at least one ingredient".to_string(),
));
}
if let Some(claim) = &manifest.claim_v2 {
if is_dataset {
let has_dataset_assertion = claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Dataset")
});
let has_dataset_assertion_in_claim = if !has_dataset_assertion {
manifest.claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Dataset")
})
} else {
false
};
if !has_dataset_assertion && !has_dataset_assertion_in_claim {
println!(
"WARNING: Dataset manifest doesn't contain a Dataset creative work assertion"
);
return Err(Error::Validation(
"Dataset manifest must contain a Dataset creative work assertion".to_string(),
));
}
}
if is_model {
let has_model_assertion = claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Model")
});
let has_model_assertion_in_claim = if !has_model_assertion {
manifest.claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Model")
})
} else {
false
};
if !has_model_assertion && !has_model_assertion_in_claim {
println!("WARNING: Model manifest doesn't contain a Model creative work assertion");
return Err(Error::Validation(
"Model manifest must contain a Model creative work assertion".to_string(),
));
}
}
if is_software {
let has_software_assertion = claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Software")
});
let has_software_parameters = claim.created_assertions.iter().any(|assertion| {
if let Assertion::Action(action_assertion) = assertion {
action_assertion.actions.iter().any(|action| {
if let Some(params) = &action.parameters {
params.get("software_type").is_some()
} else {
false
}
})
} else {
false
}
});
if !has_software_assertion && !has_software_parameters {
println!(
"WARNING: Software manifest doesn't contain a Software creative work assertion or software_type parameter"
);
return Err(Error::Validation(
"Software manifest must contain a Software creative work assertion or software_type parameter".to_string(),
));
}
}
if is_evaluation {
let has_evaluation_assertion = claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "EvaluationResult")
});
if !has_evaluation_assertion {
println!(
"WARNING: Evaluation manifest doesn't contain an EvaluationResult creative work assertion"
);
return Err(Error::Validation(
"Evaluation manifest must contain an EvaluationResult creative work assertion"
.to_string(),
));
}
}
}
Ok(())
}
fn is_dataset_manifest(manifest: &Manifest) -> bool {
if is_evaluation_manifest(manifest) {
return false;
}
let has_dataset_ingredients = manifest.ingredients.iter().any(|ingredient| {
ingredient.data.data_types.iter().any(|t| {
matches!(
t,
AssetType::Dataset
| AssetType::DatasetOnnx
| AssetType::DatasetTensorFlow
| AssetType::DatasetPytorch
)
})
});
let has_dataset_assertion = if let Some(claim) = &manifest.claim_v2 {
claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Dataset")
})
} else {
false
};
has_dataset_ingredients || has_dataset_assertion
}
fn is_model_manifest(manifest: &Manifest) -> bool {
let has_model_ingredients = manifest.ingredients.iter().any(|ingredient| {
ingredient.data.data_types.iter().any(|t| {
matches!(
t,
AssetType::Model
| AssetType::ModelOnnx
| AssetType::ModelTensorFlow
| AssetType::ModelPytorch
| AssetType::ModelOpenVino
)
})
});
let has_model_assertion = if let Some(claim) = &manifest.claim_v2 {
claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Model")
})
} else if let Some(Assertion::CreativeWork(creative_work)) = manifest
.claim
.created_assertions
.iter()
.find(|a| matches!(a, Assertion::CreativeWork(_)))
{
creative_work.creative_type == "Model"
} else {
false
};
has_model_ingredients || has_model_assertion
}
fn is_software_manifest(manifest: &Manifest) -> bool {
let has_software_ingredients = manifest.ingredients.iter().any(|ingredient| {
ingredient
.data
.data_types
.iter()
.any(|t| matches!(t, AssetType::Generator))
});
let has_software_assertion = if let Some(claim) = &manifest.claim_v2 {
claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Software")
})
} else {
false
};
let has_software_parameters = if let Some(claim) = &manifest.claim_v2 {
claim.created_assertions.iter().any(|assertion| {
if let Assertion::Action(action_assertion) = assertion {
action_assertion.actions.iter().any(|action| {
if let Some(params) = &action.parameters {
params.get("software_type").is_some()
} else {
false
}
})
} else {
false
}
})
} else {
false
};
has_software_ingredients || has_software_assertion || has_software_parameters
}
fn is_evaluation_manifest(manifest: &Manifest) -> bool {
if let Some(claim) = &manifest.claim_v2 {
claim.created_assertions.iter().any(|assertion| {
matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "EvaluationResult")
})
} else {
false
}
}
pub fn create_ingredient_from_path(
path: &Path,
name: &str,
asset_type: AssetType,
format: String,
) -> Result<Ingredient> {
create_ingredient_from_path_with_algorithm(
path,
name,
asset_type,
format,
&HashAlgorithm::Sha384,
)
}
pub fn create_ingredient_from_path_with_algorithm(
path: &Path,
name: &str,
asset_type: AssetType,
format: String,
algorithm: &HashAlgorithm,
) -> Result<Ingredient> {
let ingredient_data = IngredientData {
url: format!("file://{}", path.to_string_lossy()),
alg: algorithm.as_str().to_string(),
hash: hash::calculate_file_hash_with_algorithm(path, algorithm)?,
data_types: vec![asset_type],
linked_ingredient_url: None,
linked_ingredient_hash: None,
};
Ok(Ingredient {
title: name.to_string(),
format,
relationship: "componentOf".to_string(),
document_id: format!("uuid:{}", Uuid::new_v4()),
instance_id: format!("uuid:{}", Uuid::new_v4()),
data: ingredient_data,
linked_ingredient: None,
public_key: None,
})
}
fn get_cc_attestation_assertion() -> Result<CustomAssertion> {
let report = match cc_attestation::get_report(false) {
Ok(r) => r,
Err(e) => {
return Err(Error::CCAttestationError(format!(
"Failed to get attestation: {e}"
)));
}
};
let platform = match get_platform_name() {
Ok(p) => p,
Err(e) => {
return Err(Error::CCAttestationError(format!(
"Error detecting attestation platform: {e}"
)));
}
};
let cc_assertion = CustomAssertion {
label: platform,
data: serde_json::Value::String(report),
};
Ok(cc_assertion)
}
fn generate_oms_subject_hash(manifest: &Manifest, hash_alg: &HashAlgorithm) -> Result<String> {
if manifest.claim.ingredients.is_empty() {
return Err(Error::Validation(
"OMS requires at least one ingredient".to_string(),
));
}
let mut ingredients_to_hash = manifest.claim.ingredients.clone();
ingredients_to_hash.sort_by_key(|ingredient| ingredient.title.to_lowercase());
let mut ingredient_hashes: Vec<u8> = Vec::new();
for ingredient in &ingredients_to_hash {
let raw_bytes = hex::decode(&ingredient.data.hash).map_err(|e| {
Error::Validation(format!(
"Invalid hash for ingredient {}: {}",
ingredient.title, e
))
})?;
ingredient_hashes.extend_from_slice(&raw_bytes);
}
Ok(hash::calculate_hash_with_algorithm(
&ingredient_hashes,
hash_alg,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signing::test_utils::generate_temp_key;
fn make_test_manifest_config() -> ManifestCreationConfig {
let (_secure_key, tmp_dir) = generate_temp_key().unwrap();
ManifestCreationConfig {
name: "test-model".to_string(),
description: Some("A test model".to_string()),
author_name: Some("Test Author".to_string()),
author_org: Some("Test Org".to_string()),
paths: vec![],
ingredient_names: vec![],
hash_alg: HashAlgorithm::Sha384,
key_path: Some(tmp_dir.path().join("test_key.pem")),
output_encoding: "json".to_string(),
print: false,
storage: None,
with_cc: false,
linked_manifests: None,
custom_fields: None,
software_type: None,
version: None,
}
}
#[test]
fn test_generate_c2pa_assertions() {
let config = make_test_manifest_config();
let assertions = generate_c2pa_assertions(&config, AssetKind::Model).unwrap();
assert!(!assertions.is_empty()); }
#[test]
fn test_generate_c2pa_claim() {
let config = make_test_manifest_config();
let claim = generate_c2pa_claim(&config, AssetKind::Model).unwrap();
assert!(claim.instance_id.starts_with("urn:c2pa:"));
assert_eq!(claim.claim_generator_info, "atlas-cli:0.1.1");
}
#[test]
fn test_create_oms_manifest_no_key() {
let mut config = make_test_manifest_config();
config.key_path = None; let result = create_oms_manifest(config);
assert!(result.is_err()); }
}