atlas_cli/manifest/
common.rs

1use crate::cc_attestation;
2use crate::error::{Error, Result};
3use crate::hash;
4use crate::in_toto;
5use crate::manifest::config::ManifestCreationConfig;
6use crate::manifest::utils::{
7    determine_dataset_type, determine_format, determine_model_type, determine_software_type,
8};
9use crate::signing::signable::Signable;
10use crate::storage::traits::{ArtifactLocation, StorageBackend};
11use atlas_c2pa_lib::assertion::{
12    Action, ActionAssertion, Assertion, Author, CreativeWorkAssertion, CustomAssertion,
13};
14use atlas_c2pa_lib::asset_type::AssetType;
15use atlas_c2pa_lib::claim::ClaimV2;
16use atlas_c2pa_lib::cose::HashAlgorithm;
17use atlas_c2pa_lib::cross_reference::CrossReference;
18use atlas_c2pa_lib::datetime_wrapper::OffsetDateTimeWrapper;
19use atlas_c2pa_lib::ingredient::{Ingredient, IngredientData};
20use atlas_c2pa_lib::manifest::Manifest;
21use serde_json::{to_string, to_string_pretty};
22use std::path::{Path, PathBuf};
23use tdx_workload_attestation::get_platform_name;
24use time::OffsetDateTime;
25use uuid::Uuid;
26
27const CLAIM_GENERATOR: &str = "atlas-cli:0.2.0";
28
29/// Asset type enum to distinguish between models, datasets, software, and evaluations
30pub enum AssetKind {
31    Model,
32    Dataset,
33    Software,
34    Evaluation,
35}
36
37/// Generates C2PA Assertions based on the asset kind and configuration.
38///
39/// This function creates standardized C2PA assertions including creative work and action assertions
40/// that are tailored to the specific type of asset being attested (model, dataset, software, or evaluation).
41/// It also optionally includes confidential computing (CC) attestations when enabled.
42fn generate_c2pa_assertions(
43    config: &ManifestCreationConfig,
44    asset_kind: AssetKind,
45) -> Result<Vec<Assertion>> {
46    // Determine asset-specific values
47    let (creative_type, digital_source_type) = match asset_kind {
48        AssetKind::Model => (
49            "Model".to_string(),
50            "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia".to_string(),
51        ),
52        AssetKind::Dataset => (
53            "Dataset".to_string(),
54            "http://cv.iptc.org/newscodes/digitalsourcetype/dataset".to_string(),
55        ),
56        AssetKind::Software => (
57            "Software".to_string(),
58            "http://cv.iptc.org/newscodes/digitalsourcetype/software".to_string(),
59        ),
60        AssetKind::Evaluation => (
61            "EvaluationResult".to_string(),
62            "http://cv.iptc.org/newscodes/digitalsourcetype/evaluationResult".to_string(),
63        ),
64    };
65
66    // Create assertions
67    let mut assertions = vec![
68        Assertion::CreativeWork(CreativeWorkAssertion {
69            context: "http://schema.org/".to_string(),
70            creative_type,
71            author: vec![
72                Author {
73                    author_type: "Organization".to_string(),
74                    name: config
75                        .author_org
76                        .clone()
77                        .unwrap_or_else(|| "Organization".to_string()),
78                },
79                Author {
80                    author_type: "Person".to_string(),
81                    name: config
82                        .author_name
83                        .clone()
84                        .unwrap_or_else(|| "Unknown".to_string()),
85                },
86            ],
87        }),
88        Assertion::Action(ActionAssertion {
89            actions: vec![Action {
90                action: match asset_kind {
91                    AssetKind::Evaluation => "c2pa.evaluation".to_string(),
92                    _ => "c2pa.created".to_string(),
93                },
94                software_agent: Some(CLAIM_GENERATOR.to_string()),
95                parameters: Some(match asset_kind {
96                    AssetKind::Evaluation => {
97                        // Merge evaluation parameters with standard parameters
98                        let mut params = serde_json::json!({
99                            "name": config.name,
100                            "description": config.description,
101                            "author": {
102                                "organization": config.author_org,
103                                "name": config.author_name
104                            }
105                        });
106
107                        // Add evaluation-specific parameters if present
108                        if let Some(config_params) = &config.custom_fields {
109                            if let Some(eval_params) = config_params.get("evaluation") {
110                                if let Some(obj) = params.as_object_mut() {
111                                    obj.insert(
112                                        "model_id".to_string(),
113                                        eval_params
114                                            .get("model_id")
115                                            .cloned()
116                                            .unwrap_or(serde_json::Value::Null),
117                                    );
118                                    obj.insert(
119                                        "dataset_id".to_string(),
120                                        eval_params
121                                            .get("dataset_id")
122                                            .cloned()
123                                            .unwrap_or(serde_json::Value::Null),
124                                    );
125                                    obj.insert(
126                                        "metrics".to_string(),
127                                        eval_params
128                                            .get("metrics")
129                                            .cloned()
130                                            .unwrap_or(serde_json::Value::Null),
131                                    );
132                                }
133                            }
134                        }
135                        params
136                    }
137                    AssetKind::Software => {
138                        let mut params = serde_json::json!({
139                            "name": config.name,
140                            "description": config.description,
141                            "author": {
142                                "organization": config.author_org,
143                                "name": config.author_name
144                            }
145                        });
146
147                        if let Some(software_type) = &config.software_type {
148                            params.as_object_mut().unwrap().insert(
149                                "software_type".to_string(),
150                                serde_json::Value::String(software_type.clone()),
151                            );
152                        }
153                        if let Some(version) = &config.version {
154                            params.as_object_mut().unwrap().insert(
155                                "version".to_string(),
156                                serde_json::Value::String(version.clone()),
157                            );
158                        }
159                        params
160                    }
161                    // don't need to repeat info for created action assertions that's
162                    // already in the CreativeWork assertion
163                    _ => serde_json::json!({}),
164                }),
165                digital_source_type: Some(digital_source_type),
166                instance_id: None,
167            }],
168        }),
169    ];
170
171    // if we're creating the manifest in a CC environment, create
172    // an assertion for the CC attestation
173    if config.with_cc {
174        // the assertion contents will depend on the detected platform
175        let cc_assertion = get_cc_attestation_assertion().unwrap();
176
177        assertions.push(Assertion::CustomAssertion(cc_assertion));
178    }
179
180    Ok(assertions)
181}
182
183/// Generates a C2PA claim with ingredients and assertions.
184///
185/// This function creates a complete C2PA claim by generating ingredients from the provided file paths,
186/// sorting them alphabetically (as required by the OpenSSF Model Signing specification), and combining
187/// them with generated assertions. The claim includes metadata such as instance ID, creation timestamp,
188/// and claim generator information.
189fn generate_c2pa_claim(config: &ManifestCreationConfig, asset_kind: AssetKind) -> Result<ClaimV2> {
190    // Create ingredients using the helper function
191    let mut ingredients = Vec::new();
192
193    for (path, ingredient_name) in config.paths.iter().zip(config.ingredient_names.iter()) {
194        // Determine asset type and format based on asset kind
195        let format = determine_format(path)?;
196        let asset_type = match asset_kind {
197            AssetKind::Model => determine_model_type(path)?,
198            AssetKind::Dataset => determine_dataset_type(path)?,
199            AssetKind::Software => determine_software_type(path)?,
200            AssetKind::Evaluation => AssetType::Dataset, // Use Dataset type for evaluation results
201        };
202
203        // Use the helper function to create the ingredient
204        let ingredient = create_ingredient_from_path_with_algorithm(
205            path,
206            ingredient_name,
207            asset_type,
208            format,
209            &config.hash_alg,
210        )?;
211        ingredients.push(ingredient);
212    }
213
214    // Per the OMS spec, ingredients must be hashed in alphabetical order of the
215    // artifact name, so always canonicalize the order regardless of format
216    // because the manifest must provide references to all artifacts needed to
217    // recompute the model hash.
218    // See https://github.com/sigstore/model-transparency/blob/de2f935ad437218d577a3f39378c482bf3aafcec/src/model_signing/_signing/signing.py#L188-L192
219    ingredients.sort_by_key(|ingredient| ingredient.title.to_lowercase());
220
221    let assertions = generate_c2pa_assertions(config, asset_kind)?;
222
223    // Create claim
224    Ok(ClaimV2 {
225        instance_id: format!("urn:c2pa:{}", Uuid::new_v4()),
226        ingredients: ingredients.clone(),
227        created_assertions: assertions,
228        claim_generator_info: CLAIM_GENERATOR.to_string(),
229        signature: None,
230        created_at: OffsetDateTimeWrapper(OffsetDateTime::now_utc()),
231    })
232}
233
234/// Creates a manifest for a model, dataset, software, or evaluation
235pub fn create_manifest(config: ManifestCreationConfig, asset_kind: AssetKind) -> Result<()> {
236    let claim = generate_c2pa_claim(&config, asset_kind)?;
237
238    // Create the manifest
239    let mut manifest = Manifest {
240        claim_generator: CLAIM_GENERATOR.to_string(),
241        title: config.name.clone(),
242        instance_id: format!("urn:c2pa:{}", Uuid::new_v4()),
243        claim: claim.clone(),
244        ingredients: vec![],
245        created_at: OffsetDateTimeWrapper(OffsetDateTime::now_utc()),
246        cross_references: vec![],
247        claim_v2: Some(claim),
248        is_active: true,
249    };
250
251    // Sign if key is provided
252    if let Some(key_file) = &config.key_path {
253        manifest.sign(key_file.to_path_buf(), config.hash_alg)?;
254    }
255
256    if let Some(manifest_ids) = &config.linked_manifests {
257        if let Some(storage_backend) = &config.storage {
258            for linked_id in manifest_ids {
259                match storage_backend.retrieve_manifest(linked_id) {
260                    Ok(linked_manifest) => {
261                        // Create a JSON representation of the linked manifest
262                        let linked_json = serde_json::to_string(&linked_manifest)
263                            .map_err(|e| Error::Serialization(e.to_string()))?;
264
265                        // Create a hash of the linked manifest
266                        let linked_hash = hash::calculate_hash(linked_json.as_bytes());
267
268                        // Create a cross-reference
269                        let cross_ref = CrossReference {
270                            manifest_url: linked_id.clone(),
271                            manifest_hash: linked_hash,
272                            media_type: Some("application/json".to_string()),
273                        };
274
275                        // Add the cross-reference to the manifest
276                        manifest.cross_references.push(cross_ref);
277
278                        println!("Added link to manifest: {linked_id}");
279                    }
280                    Err(e) => {
281                        println!("Warning: Could not link to manifest {linked_id}: {e}");
282                    }
283                }
284            }
285        } else {
286            println!("Warning: Cannot link manifests without a storage backend");
287        }
288    }
289
290    // Output manifest if requested
291    if config.print || config.storage.is_none() {
292        match config.output_encoding.to_lowercase().as_str() {
293            "json" => {
294                let manifest_json =
295                    to_string_pretty(&manifest).map_err(|e| Error::Serialization(e.to_string()))?;
296                println!("{manifest_json}");
297            }
298            "cbor" => {
299                let manifest_cbor = serde_cbor::to_vec(&manifest)
300                    .map_err(|e| Error::Serialization(e.to_string()))?;
301                println!("{}", hex::encode(&manifest_cbor));
302            }
303            _ => {
304                return Err(Error::Validation(format!(
305                    "Invalid output encoding '{}'. Valid options are: json, cbor",
306                    config.output_encoding
307                )));
308            }
309        }
310    }
311
312    // Store manifest if storage is provided
313    if let Some(storage) = &config.storage {
314        if !config.print {
315            let id = storage.store_manifest(&manifest)?;
316            println!("Manifest stored successfully with ID: {id}");
317        }
318    }
319
320    Ok(())
321}
322
323/// Creates an OpenSSF Model Signing (OMS) compliant C2PA manifest for a model.
324///
325/// This function generates a manifest that conforms to the OpenSSF Model Signing specification,
326/// creating an in-toto format Statement with a DSSE (Dead Simple Signing Envelope). The manifest
327/// is specifically designed for model artifacts and includes proper subject hash calculation
328/// according to OMS requirements.
329///
330/// # Arguments
331///
332/// * `config` - The manifest creation configuration, must include a signing key for OMS format
333///
334/// # Returns
335///
336/// `Ok(())` on successful manifest creation, or an error if creation fails.
337///
338/// # Errors
339///
340/// Returns an error if:
341/// - No signing key is provided (OMS format requires signing)
342/// - Subject hash calculation fails
343/// - Manifest serialization fails
344/// - Storage operations fail
345///
346/// # Examples
347///
348/// ```no_run
349/// use atlas_cli::manifest::config::ManifestCreationConfig;
350/// use atlas_cli::manifest::common::create_oms_manifest;
351/// use atlas_c2pa_lib::cose::HashAlgorithm;
352/// use std::path::PathBuf;
353///
354/// let config = ManifestCreationConfig {
355///     name: "test-model".to_string(),
356///     description: Some("A test model".to_string()),
357///     author_name: Some("Test Author".to_string()),
358///     author_org: Some("Test Org".to_string()),
359///     paths: vec![PathBuf::from("model.onnx")],
360///     ingredient_names: vec!["model".to_string()],
361///     hash_alg: HashAlgorithm::Sha384,
362///     key_path: Some(PathBuf::from("private_key.pem")),
363///     output_encoding: "json".to_string(),
364///     print: true,
365///     storage: None,
366///     with_cc: false,
367///     linked_manifests: None,
368///     custom_fields: None,
369///     software_type: None,
370///     version: None,
371/// };
372///
373/// create_oms_manifest(config).unwrap();
374/// ```
375pub fn create_oms_manifest(config: ManifestCreationConfig) -> Result<()> {
376    let claim = generate_c2pa_claim(&config, AssetKind::Model)?;
377
378    // Create the manifest
379    let mut manifest = Manifest {
380        claim_generator: "".to_string(),
381        title: "".to_string(),
382        instance_id: format!("urn:c2pa:{}", Uuid::new_v4()),
383        claim: claim.clone(),
384        ingredients: vec![],
385        created_at: OffsetDateTimeWrapper(OffsetDateTime::now_utc()),
386        cross_references: vec![],
387        claim_v2: None,
388        is_active: true,
389    };
390
391    if let Some(manifest_ids) = &config.linked_manifests {
392        if let Some(storage_backend) = &config.storage {
393            for linked_id in manifest_ids {
394                match storage_backend.retrieve_manifest(linked_id) {
395                    Ok(linked_manifest) => {
396                        // Create a JSON representation of the linked manifest
397                        let linked_json = serde_json::to_string(&linked_manifest)
398                            .map_err(|e| Error::Serialization(e.to_string()))?;
399
400                        // Create a hash of the linked manifest
401                        let linked_hash = hash::calculate_hash(linked_json.as_bytes());
402
403                        // Create a cross-reference
404                        let cross_ref = CrossReference {
405                            manifest_url: linked_id.clone(),
406                            manifest_hash: linked_hash,
407                            media_type: Some("application/json".to_string()),
408                        };
409
410                        // Add the cross-reference to the manifest
411                        manifest.cross_references.push(cross_ref);
412
413                        println!("Added link to manifest: {linked_id}");
414                    }
415                    Err(e) => {
416                        println!("Warning: Could not link to manifest {linked_id}: {e}");
417                    }
418                }
419            }
420        } else {
421            println!("Warning: Cannot link manifests without a storage backend");
422        }
423    }
424
425    // Generate the in-toto format Statement and sign the DSSE
426
427    // we need to convert this into a string to serialize into the Struct proto expected by in-toto
428    let manifest_json = to_string(&manifest).map_err(|e| Error::Serialization(e.to_string()))?;
429    let manifest_proto = in_toto::json_to_struct_proto(&manifest_json)?;
430
431    let subject_hash = generate_oms_subject_hash(&manifest, &config.hash_alg)?;
432
433    let subject = in_toto::make_minimal_resource_descriptor(
434        &config.name,
435        hash::algorithm_to_string(&config.hash_alg),
436        &subject_hash,
437    );
438
439    let key_path = config
440        .key_path
441        .ok_or_else(|| Error::Validation("OMS format requires a signing key".to_string()))?;
442
443    let envelope = in_toto::generate_signed_statement_v1(
444        &[subject],
445        "https://spec.c2pa.org/specifications/specifications/2.2",
446        &manifest_proto,
447        key_path.to_path_buf(),
448        config.hash_alg,
449    )?;
450
451    // Output manifest if requested
452    if config.print || config.storage.is_none() {
453        match config.output_encoding.to_lowercase().as_str() {
454            "json" => {
455                let envelope_json =
456                    to_string_pretty(&envelope).map_err(|e| Error::Serialization(e.to_string()))?;
457                println!("{envelope_json}");
458            }
459            "cbor" => {
460                let envelope_cbor = serde_cbor::to_vec(&envelope)
461                    .map_err(|e| Error::Serialization(e.to_string()))?;
462                println!("{}", hex::encode(&envelope_cbor));
463            }
464            _ => {
465                return Err(Error::Validation(format!(
466                    "Invalid output encoding '{}'. Valid options are: json, cbor",
467                    config.output_encoding
468                )));
469            }
470        }
471    }
472
473    // Store manifest if storage is provided
474    if let Some(storage) = &config.storage {
475        if !config.print {
476            let id = storage.store_manifest(&manifest)?;
477            println!("Manifest stored successfully with ID: {id}");
478        }
479    }
480
481    Ok(())
482}
483
484/// Lists manifests from storage, optionally filtered by asset type.
485///
486/// This function retrieves all manifests from the provided storage backend and optionally
487/// filters them by asset kind (Model, Dataset, Software, or Evaluation). The filtered
488/// manifests are then displayed with their metadata including ID, name, type, and creation time.
489///
490/// # Arguments
491///
492/// * `storage` - The storage backend to retrieve manifests from
493/// * `asset_kind` - Optional filter for asset type; if None, all manifests are listed
494///
495/// # Returns
496///
497/// `Ok(())` on successful listing, or an error if storage retrieval fails.
498///
499/// # Examples
500///
501/// ```no_run
502/// use atlas_cli::manifest::common::{AssetKind, list_manifests};
503/// use atlas_cli::storage::traits::StorageBackend;
504/// use atlas_cli::storage::filesystem::FilesystemStorage;
505///
506/// // Create or obtain a storage backend instance
507/// let storage_backend: FilesystemStorage = FilesystemStorage::new("/path/to/storage").unwrap();
508///
509/// // List all manifests
510/// list_manifests(&storage_backend, None).unwrap();
511///
512/// // List only model manifests
513/// list_manifests(&storage_backend, Some(AssetKind::Model)).unwrap();
514/// ```
515pub fn list_manifests(storage: &dyn StorageBackend, asset_kind: Option<AssetKind>) -> Result<()> {
516    let manifests = storage.list_manifests()?;
517
518    // Filter manifests by type if asset_kind is specified
519    let filtered_manifests = if let Some(kind) = asset_kind {
520        manifests
521            .into_iter()
522            .filter(|m| match kind {
523                AssetKind::Model => {
524                    matches!(m.manifest_type, crate::storage::traits::ManifestType::Model)
525                }
526                AssetKind::Dataset => matches!(
527                    m.manifest_type,
528                    crate::storage::traits::ManifestType::Dataset
529                ),
530                AssetKind::Software => matches!(
531                    m.manifest_type,
532                    crate::storage::traits::ManifestType::Software
533                ),
534                AssetKind::Evaluation => {
535                    // Check if manifest title or name contains "Evaluation"
536                    m.name.contains("Evaluation") || m.name.contains("evaluation")
537                }
538            })
539            .collect::<Vec<_>>()
540    } else {
541        manifests
542    };
543
544    // Display the manifests
545    for metadata in filtered_manifests {
546        println!(
547            "Manifest: {} (ID: {}, Type: {:?}, Created: {})",
548            metadata.name, metadata.id, metadata.manifest_type, metadata.created_at
549        );
550    }
551
552    Ok(())
553}
554
555/// Performs comprehensive verification of a manifest.
556///
557/// This function verifies a manifest by performing multiple validation steps:
558/// 1. Validates the manifest structure against C2PA specifications
559/// 2. Verifies hash integrity of all ingredients (file-based and URL-based)
560/// 3. Validates cross-references to linked manifests
561/// 4. Checks asset-specific requirements based on manifest type
562///
563/// The verification process ensures that the manifest is structurally valid and that
564/// all referenced artifacts maintain their integrity since the manifest was created.
565///
566/// # Arguments
567///
568/// * `id` - The unique identifier of the manifest to verify
569/// * `storage` - The storage backend to retrieve the manifest and linked manifests
570///
571/// # Returns
572///
573/// `Ok(())` if verification succeeds, or an error describing the verification failure.
574///
575/// # Errors
576///
577/// Returns an error if:
578/// - Manifest cannot be retrieved from storage
579/// - Manifest structure is invalid
580/// - Any ingredient hash verification fails
581/// - Cross-reference verification fails
582/// - Asset-specific requirements are not met
583///
584/// # Examples
585///
586/// ```no_run
587/// use atlas_cli::manifest::common::verify_manifest;
588/// use atlas_cli::storage::traits::StorageBackend;
589/// use atlas_cli::storage::filesystem::FilesystemStorage;
590///
591/// // Create or obtain a storage backend instance
592/// let storage_backend: FilesystemStorage = FilesystemStorage::new("/path/to/storage").unwrap();
593///
594/// let manifest_id = "manifest-123";
595/// verify_manifest(manifest_id, &storage_backend).unwrap();
596/// println!("✓ Manifest verification successful");
597/// ```
598pub fn verify_manifest(id: &str, storage: &dyn StorageBackend) -> Result<()> {
599    let manifest = storage.retrieve_manifest(id)?;
600
601    // Step 1: Verify the manifest structure
602    atlas_c2pa_lib::manifest::validate_manifest(&manifest)
603        .map_err(|e| crate::error::Error::Validation(e.to_string()))?;
604
605    println!("Verifying manifest with ID: {id}");
606
607    // Step 2: Verify each ingredient's hash
608    for ingredient in &manifest.ingredients {
609        println!("Verifying ingredient: {}", ingredient.title);
610
611        if ingredient.data.url.starts_with("file://") {
612            let path = PathBuf::from(ingredient.data.url.trim_start_matches("file://"));
613
614            // Create ArtifactLocation for verification
615            let location = ArtifactLocation {
616                url: ingredient.data.url.clone(),
617                file_path: Some(path),
618                hash: ingredient.data.hash.clone(),
619            };
620
621            // Verify the hash and handle the result
622            match location.verify() {
623                Ok(true) => {
624                    println!(
625                        "✓ Successfully verified hash for component: {}",
626                        ingredient.title
627                    );
628                }
629                Ok(false) => {
630                    return Err(Error::Validation(format!(
631                        "Hash verification failed for component: {}. The file may have been modified.",
632                        ingredient.title
633                    )));
634                }
635                Err(e) => {
636                    return Err(Error::Validation(format!(
637                        "Error verifying component {}: {}. The file may be missing or inaccessible.",
638                        ingredient.title, e
639                    )));
640                }
641            }
642        } else {
643            // For non-file URLs, try direct hash verification
644            match hash::calculate_file_hash(PathBuf::from(&ingredient.data.url)) {
645                Ok(calculated_hash) => {
646                    if calculated_hash != ingredient.data.hash {
647                        return Err(Error::Validation(format!(
648                            "Hash mismatch for ingredient: {}",
649                            ingredient.title
650                        )));
651                    }
652                    println!(
653                        "✓ Successfully verified hash for component: {}",
654                        ingredient.title
655                    );
656                }
657                Err(_) => {
658                    println!(
659                        "⚠ Warning: Component {} does not use file:// URL scheme and could not be verified directly",
660                        ingredient.title
661                    );
662                }
663            }
664        }
665    }
666
667    // Step 3: Verify cross-references if present
668    if !manifest.cross_references.is_empty() {
669        println!("Verifying cross-references...");
670
671        for cross_ref in &manifest.cross_references {
672            let linked_manifest = storage.retrieve_manifest(&cross_ref.manifest_url)?;
673            let manifest_json = serde_json::to_string(&linked_manifest)
674                .map_err(|e| Error::Serialization(e.to_string()))?;
675            let algorithm = hash::detect_hash_algorithm(&cross_ref.manifest_hash);
676            let calculated_hash =
677                hash::calculate_hash_with_algorithm(manifest_json.as_bytes(), &algorithm);
678
679            if calculated_hash != cross_ref.manifest_hash {
680                return Err(Error::Validation(format!(
681                    "Cross-reference verification failed for linked manifest: {}. Hash mismatch: stored={}, calculated={}",
682                    cross_ref.manifest_url, cross_ref.manifest_hash, calculated_hash
683                )));
684            }
685            println!(
686                "✓ Verified cross-reference to manifest: {}",
687                cross_ref.manifest_url
688            );
689        }
690    }
691
692    // Step 4: Verify asset-specific requirements
693    verify_asset_specific_requirements(&manifest)?;
694
695    println!("✓ Manifest verification successful");
696    Ok(())
697}
698
699// Verify asset-specific requirements based on the manifest content
700fn verify_asset_specific_requirements(manifest: &Manifest) -> Result<()> {
701    // Determines the asset type from the manifest contents
702    let is_dataset = is_dataset_manifest(manifest);
703    let is_model = is_model_manifest(manifest);
704    let is_software = is_software_manifest(manifest);
705    let is_evaluation = is_evaluation_manifest(manifest);
706
707    // Verify that at least one ingredient exists (except for evaluations)
708    if !is_evaluation && manifest.ingredients.is_empty() {
709        return Err(Error::Validation(
710            "Manifest must contain at least one ingredient".to_string(),
711        ));
712    }
713
714    // Check for dataset, model, software, or evaluation assertion
715    if let Some(claim) = &manifest.claim_v2 {
716        if is_dataset {
717            let has_dataset_assertion = claim.created_assertions.iter().any(|assertion| {
718                matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Dataset")
719            });
720
721            let has_dataset_assertion_in_claim = if !has_dataset_assertion {
722                manifest.claim.created_assertions.iter().any(|assertion| {
723                    matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Dataset")
724                })
725            } else {
726                false
727            };
728
729            if !has_dataset_assertion && !has_dataset_assertion_in_claim {
730                println!(
731                    "WARNING: Dataset manifest doesn't contain a Dataset creative work assertion"
732                );
733
734                return Err(Error::Validation(
735                    "Dataset manifest must contain a Dataset creative work assertion".to_string(),
736                ));
737            }
738        }
739
740        if is_model {
741            let has_model_assertion = claim.created_assertions.iter().any(|assertion| {
742                matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Model")
743            });
744
745            let has_model_assertion_in_claim = if !has_model_assertion {
746                manifest.claim.created_assertions.iter().any(|assertion| {
747                    matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Model")
748                })
749            } else {
750                false
751            };
752
753            if !has_model_assertion && !has_model_assertion_in_claim {
754                println!("WARNING: Model manifest doesn't contain a Model creative work assertion");
755
756                return Err(Error::Validation(
757                    "Model manifest must contain a Model creative work assertion".to_string(),
758                ));
759            }
760        }
761
762        if is_software {
763            let has_software_assertion = claim.created_assertions.iter().any(|assertion| {
764                matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Software")
765            });
766
767            let has_software_parameters = claim.created_assertions.iter().any(|assertion| {
768                if let Assertion::Action(action_assertion) = assertion {
769                    action_assertion.actions.iter().any(|action| {
770                        if let Some(params) = &action.parameters {
771                            params.get("software_type").is_some()
772                        } else {
773                            false
774                        }
775                    })
776                } else {
777                    false
778                }
779            });
780
781            if !has_software_assertion && !has_software_parameters {
782                println!(
783                    "WARNING: Software manifest doesn't contain a Software creative work assertion or software_type parameter"
784                );
785
786                return Err(Error::Validation(
787                    "Software manifest must contain a Software creative work assertion or software_type parameter".to_string(),
788                ));
789            }
790        }
791
792        if is_evaluation {
793            let has_evaluation_assertion = claim.created_assertions.iter().any(|assertion| {
794                matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "EvaluationResult")
795            });
796
797            if !has_evaluation_assertion {
798                println!(
799                    "WARNING: Evaluation manifest doesn't contain an EvaluationResult creative work assertion"
800                );
801
802                return Err(Error::Validation(
803                    "Evaluation manifest must contain an EvaluationResult creative work assertion"
804                        .to_string(),
805                ));
806            }
807        }
808    }
809
810    Ok(())
811}
812
813// Helper function to determine if a manifest is for a dataset
814fn is_dataset_manifest(manifest: &Manifest) -> bool {
815    // Check if it's an evaluation manifest - if so, it's NOT a dataset
816    if is_evaluation_manifest(manifest) {
817        return false;
818    }
819
820    // Now proceed with the regular dataset checking
821    let has_dataset_ingredients = manifest.ingredients.iter().any(|ingredient| {
822        ingredient.data.data_types.iter().any(|t| {
823            matches!(
824                t,
825                AssetType::Dataset
826                    | AssetType::DatasetOnnx
827                    | AssetType::DatasetTensorFlow
828                    | AssetType::DatasetPytorch
829            )
830        })
831    });
832
833    let has_dataset_assertion = if let Some(claim) = &manifest.claim_v2 {
834        claim.created_assertions.iter().any(|assertion| {
835            matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Dataset")
836        })
837    } else {
838        false
839    };
840
841    has_dataset_ingredients || has_dataset_assertion
842}
843
844// Helper function to determine if a manifest is for a model
845fn is_model_manifest(manifest: &Manifest) -> bool {
846    // Check if any ingredients have model type
847    let has_model_ingredients = manifest.ingredients.iter().any(|ingredient| {
848        ingredient.data.data_types.iter().any(|t| {
849            matches!(
850                t,
851                AssetType::Model
852                    | AssetType::ModelOnnx
853                    | AssetType::ModelTensorFlow
854                    | AssetType::ModelPytorch
855                    | AssetType::ModelOpenVino
856            )
857        })
858    });
859
860    // Check for model assertion
861    let has_model_assertion = if let Some(claim) = &manifest.claim_v2 {
862        claim.created_assertions.iter().any(|assertion| {
863            matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Model")
864        })
865    } else if let Some(Assertion::CreativeWork(creative_work)) = manifest
866        .claim
867        .created_assertions
868        .iter()
869        .find(|a| matches!(a, Assertion::CreativeWork(_)))
870    {
871        // Check in the old claim field as a fallback
872        creative_work.creative_type == "Model"
873    } else {
874        false
875    };
876
877    // Returns true if either condition is met
878    has_model_ingredients || has_model_assertion
879}
880
881// Helper function to check if a manifest is a software manifest
882fn is_software_manifest(manifest: &Manifest) -> bool {
883    // Check if any ingredients have software type
884    let has_software_ingredients = manifest.ingredients.iter().any(|ingredient| {
885        ingredient
886            .data
887            .data_types
888            .iter()
889            .any(|t| matches!(t, AssetType::Generator))
890    });
891
892    // Check for software assertion
893    let has_software_assertion = if let Some(claim) = &manifest.claim_v2 {
894        claim.created_assertions.iter().any(|assertion| {
895            matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "Software")
896        })
897    } else {
898        false
899    };
900
901    // Check for software parameters in actions
902    let has_software_parameters = if let Some(claim) = &manifest.claim_v2 {
903        claim.created_assertions.iter().any(|assertion| {
904            if let Assertion::Action(action_assertion) = assertion {
905                action_assertion.actions.iter().any(|action| {
906                    if let Some(params) = &action.parameters {
907                        params.get("software_type").is_some()
908                    } else {
909                        false
910                    }
911                })
912            } else {
913                false
914            }
915        })
916    } else {
917        false
918    };
919
920    has_software_ingredients || has_software_assertion || has_software_parameters
921}
922
923// Helper function to check if a manifest is an evaluation manifest
924fn is_evaluation_manifest(manifest: &Manifest) -> bool {
925    if let Some(claim) = &manifest.claim_v2 {
926        claim.created_assertions.iter().any(|assertion| {
927            matches!(assertion, Assertion::CreativeWork(creative_work) if creative_work.creative_type == "EvaluationResult")
928        })
929    } else {
930        false
931    }
932}
933
934/// Create a C2PA Ingredient from a path
935pub fn create_ingredient_from_path(
936    path: &Path,
937    name: &str,
938    asset_type: AssetType,
939    format: String,
940) -> Result<Ingredient> {
941    create_ingredient_from_path_with_algorithm(
942        path,
943        name,
944        asset_type,
945        format,
946        &HashAlgorithm::Sha384,
947    )
948}
949
950/// Create a C2PA Ingredient from a path with a specified hash algorithm
951pub fn create_ingredient_from_path_with_algorithm(
952    path: &Path,
953    name: &str,
954    asset_type: AssetType,
955    format: String,
956    algorithm: &HashAlgorithm,
957) -> Result<Ingredient> {
958    let ingredient_data = IngredientData {
959        url: format!("file://{}", path.to_string_lossy()),
960        alg: algorithm.as_str().to_string(),
961        hash: hash::calculate_file_hash_with_algorithm(path, algorithm)?,
962        data_types: vec![asset_type],
963        linked_ingredient_url: None,
964        linked_ingredient_hash: None,
965    };
966
967    Ok(Ingredient {
968        title: name.to_string(),
969        format,
970        relationship: "componentOf".to_string(),
971        document_id: format!("uuid:{}", Uuid::new_v4()),
972        instance_id: format!("uuid:{}", Uuid::new_v4()),
973        data: ingredient_data,
974        linked_ingredient: None,
975        public_key: None,
976    })
977}
978
979/// Helper function to generate a CC attestation assertion
980fn get_cc_attestation_assertion() -> Result<CustomAssertion> {
981    let report = match cc_attestation::get_report(false) {
982        Ok(r) => r,
983        Err(e) => {
984            return Err(Error::CCAttestationError(format!(
985                "Failed to get attestation: {e}"
986            )));
987        }
988    };
989
990    // detect the underlying CC platform
991    let platform = match get_platform_name() {
992        Ok(p) => p,
993        Err(e) => {
994            return Err(Error::CCAttestationError(format!(
995                "Error detecting attestation platform: {e}"
996            )));
997        }
998    };
999
1000    let cc_assertion = CustomAssertion {
1001        label: platform,
1002        data: serde_json::Value::String(report),
1003    };
1004
1005    Ok(cc_assertion)
1006}
1007
1008// Compute the OMS subject hash as specified in https://github.com/sigstore/model-transparency/blob/de2f935ad437218d577a3f39378c482bf3aafcec/src/model_signing/_signing/signing.py#L181-L186
1009fn generate_oms_subject_hash(manifest: &Manifest, hash_alg: &HashAlgorithm) -> Result<String> {
1010    // generate the hash over all ingredient hashes for the model
1011    if manifest.claim.ingredients.is_empty() {
1012        return Err(Error::Validation(
1013            "OMS requires at least one ingredient".to_string(),
1014        ));
1015    }
1016
1017    // Per the OMS spec, the ingredients must be hashed in a canonical order
1018    // (alphabetical order of artifact name)
1019    // Since we cannot assume that the ingredients in the manifest are sorted
1020    // as expected (e.g., during verification), we sort every time we hash.
1021    let mut ingredients_to_hash = manifest.claim.ingredients.clone();
1022    ingredients_to_hash.sort_by_key(|ingredient| ingredient.title.to_lowercase());
1023
1024    let mut ingredient_hashes: Vec<u8> = Vec::new();
1025    for ingredient in &ingredients_to_hash {
1026        let raw_bytes = hex::decode(&ingredient.data.hash).map_err(|e| {
1027            Error::Validation(format!(
1028                "Invalid hash for ingredient {}: {}",
1029                ingredient.title, e
1030            ))
1031        })?;
1032        ingredient_hashes.extend_from_slice(&raw_bytes);
1033    }
1034
1035    Ok(hash::calculate_hash_with_algorithm(
1036        &ingredient_hashes,
1037        hash_alg,
1038    ))
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use crate::signing::test_utils::generate_temp_key;
1045
1046    fn make_test_manifest_config() -> ManifestCreationConfig {
1047        let (_secure_key, tmp_dir) = generate_temp_key().unwrap();
1048
1049        ManifestCreationConfig {
1050            name: "test-model".to_string(),
1051            description: Some("A test model".to_string()),
1052            author_name: Some("Test Author".to_string()),
1053            author_org: Some("Test Org".to_string()),
1054            paths: vec![],
1055            ingredient_names: vec![],
1056            hash_alg: HashAlgorithm::Sha384,
1057            key_path: Some(tmp_dir.path().join("test_key.pem")),
1058            output_encoding: "json".to_string(),
1059            print: false,
1060            storage: None,
1061            with_cc: false,
1062            linked_manifests: None,
1063            custom_fields: None,
1064            software_type: None,
1065            version: None,
1066        }
1067    }
1068
1069    #[test]
1070    fn test_generate_c2pa_assertions() {
1071        let config = make_test_manifest_config();
1072
1073        let assertions = generate_c2pa_assertions(&config, AssetKind::Model).unwrap();
1074        assert!(!assertions.is_empty()); // Should have at least the CreativeWork assertion
1075    }
1076
1077    #[test]
1078    fn test_generate_c2pa_claim() {
1079        let config = make_test_manifest_config();
1080        let claim = generate_c2pa_claim(&config, AssetKind::Model).unwrap();
1081        assert!(claim.instance_id.starts_with("urn:c2pa:"));
1082        assert_eq!(claim.claim_generator_info, "atlas-cli:0.1.1");
1083    }
1084
1085    // #[test]
1086    // fn test_create_manifest() -> Result<()>{
1087    //     let config = make_test_manifest_config();
1088    //     let result = create_manifest(config, AssetKind::Model);
1089    //     assert!(result.is_ok()); // Should succeed even with no ingredients
1090
1091    //     Ok(())
1092    // }
1093
1094    // #[test]
1095    // fn test_create_oms_manifest() -> Result<()> {
1096    //     let config = make_test_manifest_config();
1097    //     let result = create_oms_manifest(config);
1098    //     assert!(result.is_ok()); // Should succeed with the provided key
1099
1100    //     Ok(())
1101    // }
1102
1103    #[test]
1104    fn test_create_oms_manifest_no_key() {
1105        let mut config = make_test_manifest_config();
1106        config.key_path = None; // Remove the key path to simulate missing key
1107        let result = create_oms_manifest(config);
1108        assert!(result.is_err()); // Should fail because OMS requires a signing key
1109    }
1110}