diff --git a/.gitignore b/.gitignore index 948a98e8a..f725d4573 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .vscode /semver-checks/target/ +sdk/target/ # Unit test output lands here. TO DO: Fix cli/target diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json new file mode 100644 index 000000000..87ebcae10 --- /dev/null +++ b/cli/schemas/crJSON-schema.json @@ -0,0 +1,1838 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contentcredentials.org/crjson/crJSON-schema.json", + "title": "Content Credential JSON (CrJSON) Document Schema", + "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", + "type": "object", + "required": [ + "@context", + "manifests", + "jsonGenerator" + ], + "properties": { + "@context": { + "description": "JSON-LD context defining namespaces and vocabularies", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "minItems": 1 + }, + { + "type": "object", + "properties": { + "@vocab": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "manifests": { + "description": "Array of C2PA manifests embedded in the asset", + "type": "array", + "items": { + "$ref": "#/definitions/manifest" + } + }, + "validationInfo": { + "description": "Validation information summary and validation time (signature codes, trust, content). Document-level.", + "$ref": "#/definitions/validationInfo" + }, + "jsonGenerator": { + "description": "Information about the tool used to generate this JSON, and when it was generated", + "$ref": "#/definitions/jsonGenerator" + } + }, + "additionalProperties": true, + "definitions": { + "jsonGenerator": { + "type": "object", + "description": "Information about the claim generator (generator-info fields) plus date stamp. CrJSON document-level; name/version align with claim.cddl generator-info-map; date is CrJSON-specific.", + "properties": { + "name": { + "type": "string", + "description": "Name of the software" + }, + "version": { + "type": "string", + "description": "Version of the software (SemVer 2.0: major.minor.patch with optional pre-release and build)", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Date and time when the CrJSON was generated (ISO 8601)" + } + }, + "required": [ + "name", + "version", + "date" + ], + "additionalProperties": true + }, + "manifest": { + "type": "object", + "description": "CrJSON manifest wrapper containing label, assertions, claim or claim.v2, signature. Claim content: claim.cddl claim-map or claim-map-v2.", + "required": [ + "label", + "assertions", + "signature" + ], + "oneOf": [ + { + "required": [ + "claim" + ] + }, + { + "required": [ + "claim.v2" + ] + } + ], + "properties": { + "label": { + "type": "string", + "description": "Manifest label or URN identifier" + }, + "assertions": { + "type": "object", + "description": "Assertions contained within the manifest, keyed by the assertion's label", + "additionalProperties": true + }, + "claim": { + "$ref": "#/definitions/claimV1", + "description": "Claim map (v1) per C2PA CDDL claim-map; alternative to claim.v2. A manifest SHALL contain either claim or claim.v2." + }, + "claim.v2": { + "$ref": "#/definitions/claim", + "description": "Normalized claim (v2) per C2PA CDDL claim-map-v2; alternative to claim. A manifest SHALL contain either claim or claim.v2." + }, + "signature": { + "$ref": "#/definitions/signature" + }, + "validationResults": { + "$ref": "#/definitions/statusCodes", + "description": "Validation status codes for this manifest (success, informational, failure)" + }, + "ingredientDeltas": { + "type": "array", + "description": "Validation deltas for this manifest's ingredient assertions. Present when ingredients are C2PA assets.", + "items": { + "$ref": "#/definitions/ingredientDeltaValidationResult" + } + } + }, + "additionalProperties": false + }, + "claimV1": { + "type": "object", + "description": "Claim map (v1). CDDL: claim.cddl claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", + "required": [ + "claim_generator", + "claim_generator_info", + "signature", + "assertions", + "dc:format", + "instanceID" + ], + "properties": { + "claim_generator": { + "type": "string", + "description": "User-Agent string for the claim generator that created the claim (RFC 7231 ยง5.5.3)" + }, + "claim_generator_info": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softwareAgent" + }, + "description": "Detailed information about the claim generator (one or more generator-info maps)" + }, + "signature": { + "type": "string", + "description": "JUMBF URI reference to the signature of this claim" + }, + "assertions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to assertions in this claim" + }, + "dc:format": { + "type": "string", + "description": "Media type of the asset" + }, + "instanceID": { + "type": "string", + "description": "Uniquely identifies a specific version of an asset" + }, + "dc:title": { + "type": "string", + "description": "Name of the asset (Dublin Core)" + }, + "redacted_assertions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JUMBF URI references to redacted ingredient manifest assertions" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" + }, + "alg_soft": { + "type": "string", + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "hashedUriMap": { + "type": "object", + "description": "Hashed URI reference (url and hash). CDDL: hashed-uri.cddl $hashed-uri-map.", + "required": [ + "url", + "hash" + ], + "properties": { + "url": { + "type": "string", + "description": "JUMBF URI reference" + }, + "hash": { + "type": "string", + "description": "Hash value (e.g. Base64-encoded)" + }, + "alg": { + "type": "string", + "description": "Hash algorithm identifier; if absent, taken from enclosing structure" + } + }, + "additionalProperties": false + }, + "assertionMetadataMap": { + "type": "object", + "description": "Additional information about an assertion. CDDL: assertion-metadata-common.cddl $assertion-metadata-map.", + "properties": { + "dateTime": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 date-time when the assertion was created/generated (tdate)" + }, + "reviewRatings": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/ratingMap" + }, + "description": "Ratings given to the assertion" + }, + "reference": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to another assertion that this review is about" + }, + "dataSource": { + "$ref": "#/definitions/sourceMap", + "description": "Description of the source of the assertion data" + }, + "localizations": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/localizationDataEntry" + }, + "description": "Localizations for strings in the assertion" + }, + "regionOfInterest": { + "$ref": "#/definitions/regionMap", + "description": "Region of the asset where this assertion is relevant ($region-map)" + } + }, + "additionalProperties": true + }, + "sourceMap": { + "type": "object", + "description": "Source of assertion data. CDDL: assertion-metadata-common.cddl source-map.", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "Source type; $source-type", + "enum": [ + "signer", + "claimGenerator.REE", + "claimGenerator.TEE", + "localProvider.REE", + "localProvider.TEE", + "remoteProvider.1stParty", + "remoteProvider.3rdParty", + "humanEntry", + "humanEntry.anonymous", + "humanEntry.identified" + ] + }, + "details": { + "type": "string", + "description": "Human-readable details about the source, e.g. URL of the remote server" + } + }, + "additionalProperties": false + }, + "ratingMap": { + "type": "object", + "description": "Rating of an assertion item. CDDL: assertion-metadata-common.cddl rating-map.", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Rating from 1 (worst) to 5 (best)" + }, + "code": { + "type": "string", + "description": "Label-formatted reason for the rating; $review-code", + "enum": [ + "actions.unknownActionsPerformed", + "actions.missing", + "actions.possiblyMissing", + "depthMap.sceneMismatch", + "ingredient.modified", + "ingredient.possiblyModified", + "thumbnail.primaryMismatch", + "stds.iptc.location.inaccurate", + "stds.schema-org.CreativeWork.misattributed", + "stds.schema-org.CreativeWork.missingAttribution" + ] + }, + "explanation": { + "type": "string", + "description": "Human-readable explanation for the rating" + } + }, + "additionalProperties": false + }, + "localizationDataEntry": { + "type": "object", + "description": "Localization dictionary (language keys to string). CDDL: assertion-metadata-common.cddl $localization-data-entry.", + "additionalProperties": { + "type": "string" + } + }, + "claim": { + "type": "object", + "description": "Claim (v2). CDDL: claim.cddl claim-map-v2.", + "required": [ + "instanceID", + "claim_generator_info", + "signature", + "created_assertions" + ], + "properties": { + "instanceID": { + "type": "string", + "description": "Uniquely identifies a specific version of an asset" + }, + "claim_generator_info": { + "$ref": "#/definitions/softwareAgent", + "description": "The claim generator of this claim (single generator-info map)" + }, + "signature": { + "type": "string", + "description": "JUMBF URI reference to the signature of this claim" + }, + "created_assertions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to created assertions" + }, + "gathered_assertions": { + "type": "array", + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to gathered assertions" + }, + "dc:title": { + "type": "string", + "description": "Name of the asset (Dublin Core)" + }, + "redacted_assertions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JUMBF URI references to the assertions of ingredient manifests being redacted" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" + }, + "alg_soft": { + "type": "string", + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" + }, + "specVersion": { + "type": "string", + "description": "The version of the specification used to produce this claim (SemVer)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap", + "description": "Additional information about the assertion (DEPRECATED)" + } + }, + "additionalProperties": false + }, + "assertions": { + "type": "object", + "description": "Container of assertion objects keyed by assertion label. Each key (e.g. c2pa.hash.data, c2pa.actions) corresponds to a C2PA assertion type; see definitions and INTERNAL/cddl for data-hash, boxes-hash, actions, ingredient, etc.", + "properties": { + "c2pa.hash.data": { + "$ref": "#/definitions/hashDataAssertion" + }, + "c2pa.hash.bmff.v2": { + "$ref": "#/definitions/hashBmffAssertion" + }, + "c2pa.hash.boxes": { + "$ref": "#/definitions/hashBoxesAssertion" + }, + "c2pa.hash.multi-asset": { + "$ref": "#/definitions/hashMultiAssetAssertion" + }, + "c2pa.actions": { + "$ref": "#/definitions/actionsAssertionV1" + }, + "c2pa.actions.v2": { + "$ref": "#/definitions/actionsAssertionV2" + }, + "c2pa.ingredient": { + "$ref": "#/definitions/ingredientAssertionV1" + }, + "c2pa.ingredient.v2": { + "$ref": "#/definitions/ingredientAssertionV2" + }, + "c2pa.ingredient.v3": { + "$ref": "#/definitions/ingredientAssertionV3" + }, + "c2pa.thumbnail.claim.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.thumbnail.ingredient.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.soft-binding": { + "$ref": "#/definitions/softBindingAssertion" + } + }, + "patternProperties": { + "^c2pa\\.thumbnail\\.ingredient": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "^c2pa\\.hash\\.(data|bmff\\.v2|boxes)\\.part(__[0-9]+)?$": { + "description": "Part hash assertion: standard hard binding assertion with .part and optional multiple instance identifier (e.g. c2pa.hash.data.part, c2pa.hash.data.part__2). Byte offsets in the assertion are relative to the part.", + "oneOf": [ + { + "$ref": "#/definitions/hashDataAssertion" + }, + { + "$ref": "#/definitions/hashBmffAssertion" + }, + { + "$ref": "#/definitions/hashBoxesAssertion" + } + ] + }, + "^c2pa\\.actions\\.v2(__[0-9]+)?$": { + "description": "Actions assertion (v2) with optional multiple instance identifier (e.g. c2pa.actions.v2, c2pa.actions.v2__1).", + "$ref": "#/definitions/actionsAssertionV2" + } + }, + "additionalProperties": true + }, + "hashDataAssertion": { + "type": "object", + "description": "Data hash assertion (c2pa.hash.data). CDDL: data-hash.cddl data-hash-map, EXCLUSION_RANGE-map.", + "properties": { + "alg": { + "type": "string", + "description": "Hash algorithm" + }, + "hash": { + "type": "string", + "description": "Base64-encoded hash value" + }, + "pad": { + "type": "string", + "description": "Padding bytes" + }, + "pad2": { + "type": "string", + "description": "Secondary padding bytes" + }, + "name": { + "type": "string", + "description": "Name of the hashed data" + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "Starting byte position of exclusion" + }, + "length": { + "type": "integer", + "description": "Length of exclusion in bytes" + } + }, + "required": [ + "start", + "length" + ] + } + } + }, + "required": [ + "hash", + "pad" + ], + "additionalProperties": false + }, + "hashBoxesAssertion": { + "type": "object", + "description": "Boxes hash assertion (c2pa.hash.boxes). CDDL: boxes-hash.cddl box-map.", + "required": [ + "boxes" + ], + "properties": { + "boxes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/boxHashMap" + }, + "description": "Array of box-hash-map entries" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm (C2PA hash algorithm identifier). If absent, taken from enclosing structure." + } + }, + "additionalProperties": false + }, + "boxHashMap": { + "type": "object", + "description": "Single box hash entry. CDDL: boxes-hash.cddl box-hash-map.", + "required": [ + "names", + "hash" + ], + "properties": { + "names": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 10, + "description": "Box identifier in order of appearance (e.g. APP0, IHDR); box-name" + }, + "description": "Box identifiers in order of appearance" + }, + "alg": { + "type": "string", + "description": "Hash algorithm for this box; if absent, from enclosing structure" + }, + "hash": { + "type": "string", + "description": "Hash value (byte string; in JSON typically Base64-encoded)" + }, + "excluded": { + "type": "boolean", + "description": "If true, a validator can ignore this box (and associated hash) during validation" + }, + "exclusions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/boxExclusionsMap" + }, + "description": "Hash exclusion ranges; ranges have monotonically increasing start values and do not overlap" + }, + "pad": { + "type": "string", + "description": "Zero-filled byte string used for filling up space (typically Base64 in JSON)" + }, + "pad2": { + "type": "string", + "description": "Zero-filled byte string used for filling up space (typically Base64 in JSON)" + } + }, + "additionalProperties": false + }, + "boxExclusionsMap": { + "type": "object", + "description": "Exclusion range for a box. CDDL: boxes-hash.cddl box-exclusions-map.", + "required": [ + "start", + "length" + ], + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "description": "Starting byte offset from the start of the box (uint)" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Number of bytes of data to exclude (uint)" + }, + "boxIndex": { + "type": "integer", + "description": "0-based index into the names array in the box-hash-map; can be omitted if the number of boxes is one" + } + }, + "additionalProperties": false + }, + "hashMultiAssetAssertion": { + "type": "object", + "description": "Multi-asset hash assertion (c2pa.hash.multi-asset). CDDL: multi-asset-hash.cddl multi-asset-hash-map.", + "required": [ + "parts" + ], + "properties": { + "parts": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/partHashMap" + }, + "description": "Array of hashes for individual parts of the multi-part file" + } + }, + "additionalProperties": false + }, + "partHashMap": { + "type": "object", + "description": "Part hash entry. CDDL: multi-asset-hash.cddl part-hash-map.", + "required": [ + "location", + "hashAssertion" + ], + "properties": { + "location": { + "$ref": "#/definitions/locatorMap", + "description": "Location of the part within the file (byte range or BMFF box path)" + }, + "hashAssertion": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the hash assertion for the part (e.g. c2pa.hash.data.part, c2pa.hash.data.part__2). Byte offsets in that assertion are relative to the part." + }, + "optional": { + "type": "boolean", + "description": "If true, the part is optional and can be discarded" + } + }, + "additionalProperties": false + }, + "locatorMap": { + "type": "object", + "description": "Location of a part (byte range or BMFF box path). CDDL: multi-asset-hash.cddl locator-map.", + "oneOf": [ + { + "required": [ + "byteOffset", + "length" + ], + "properties": { + "byteOffset": { + "type": "integer", + "minimum": 0, + "description": "Byte offset of the part within the file (uint)" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Length of the part in bytes (uint)" + } + }, + "additionalProperties": false + }, + { + "required": [ + "bmffBox" + ], + "properties": { + "bmffBox": { + "type": "string", + "description": "XPath to the BMFF box of the part" + } + }, + "additionalProperties": false + } + ] + }, + "softBindingAssertion": { + "type": "object", + "description": "Soft binding assertion (c2pa.soft-binding). CDDL: soft-binding.cddl soft-binding-map.", + "required": [ + "alg", + "blocks" + ], + "properties": { + "alg": { + "type": "string", + "description": "Soft binding algorithm and version from C2PA soft binding algorithm list. If absent, taken from enclosing alg_soft." + }, + "blocks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softBindingBlockMap" + }, + "description": "One or more soft bindings across some or all of the asset's content" + }, + "pad": { + "type": "string", + "description": "Zero-filled byte string for filling space (typically Base64 in JSON)" + }, + "pad2": { + "type": "string", + "description": "Zero-filled byte string for filling space (typically Base64 in JSON)" + }, + "name": { + "type": "string", + "description": "Human-readable description of what this hash covers" + }, + "alg-params": { + "type": "string", + "description": "CBOR byte string (e.g. Base64 in JSON) describing parameters of the soft binding algorithm" + }, + "bindingMetadata": { + "$ref": "#/definitions/softBindingMetadataMap", + "description": "Additional metadata of the soft binding" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Unused and deprecated" + } + }, + "additionalProperties": false + }, + "softBindingBlockMap": { + "type": "object", + "description": "Single soft binding block. CDDL: soft-binding.cddl soft-binding-block-map.", + "required": [ + "scope", + "value" + ], + "properties": { + "scope": { + "$ref": "#/definitions/softBindingScopeMap", + "description": "Scope of the digital content over which the soft binding is computed" + }, + "value": { + "type": "string", + "description": "CBOR byte string (e.g. Base64 in JSON), in algorithm-specific format, for the soft binding value over this block" + } + }, + "additionalProperties": false + }, + "softBindingScopeMap": { + "type": "object", + "description": "Scope of content for a soft binding block. CDDL: soft-binding.cddl soft-binding-scope-map.", + "properties": { + "extent": { + "type": "string", + "description": "Deprecated. CBOR byte string (e.g. Base64) describing the part of content over which the binding was computed" + }, + "timespan": { + "$ref": "#/definitions/softBindingTimespanMap", + "description": "Time range over which the soft binding value has been computed" + }, + "region": { + "$ref": "#/definitions/regionMap", + "description": "Region of interest (region-map)" + } + }, + "additionalProperties": false + }, + "softBindingTimespanMap": { + "type": "object", + "description": "Time range for soft binding. CDDL: soft-binding.cddl soft-binding-timespan-map.", + "required": [ + "start", + "end" + ], + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "description": "Start of the time range in milliseconds from media start (uint)" + }, + "end": { + "type": "integer", + "minimum": 0, + "description": "End of the time range in milliseconds from media start (uint)" + } + }, + "additionalProperties": false + }, + "softBindingMetadataMap": { + "type": "object", + "description": "Additional metadata for soft binding. CDDL: soft-binding.cddl soft-binding-metadata-map.", + "properties": { + "description": { + "type": "string", + "description": "Additional description of the implementation or author of the binding or algorithm" + }, + "contact": { + "type": "string", + "description": "Contact information for the implementation or author of the binding or algorithm" + }, + "informationalUrl": { + "type": "string", + "description": "Web page with more details about the implementation or author of the binding or algorithm" + } + }, + "additionalProperties": true + }, + "actionsAssertionV1": { + "type": "object", + "description": "Actions assertion (v1) (c2pa.actions). CDDL: actions.cddl actions-map.", + "properties": { + "actions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionItemV1" + }, + "description": "List of actions (action-items-map)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "required": [ + "actions" + ], + "additionalProperties": false + }, + "actionItemV1": { + "type": "object", + "description": "Single action (v1). CDDL: actions.cddl action-items-map.", + "properties": { + "action": { + "type": "string", + "description": "Action type (e.g., c2pa.created, c2pa.edited, c2pa.converted); $action-choice" + }, + "when": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the action occurred (tdate)" + }, + "softwareAgent": { + "type": "string", + "description": "The software agent that performed the action" + }, + "changed": { + "type": "string", + "description": "Semicolon-delimited list of parts of the resource that were changed since the previous event history" + }, + "instanceID": { + "type": "string", + "description": "xmpMM:InstanceID for the modified (output) resource (buuid)" + }, + "parameters": { + "$ref": "#/definitions/parametersMapV1", + "description": "Additional parameters of the action" + }, + "digitalSourceType": { + "type": "string", + "description": "One of the defined source types at https://cv.iptc.org/newscodes/digitalsourcetype/" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "parametersMapV1": { + "type": "object", + "description": "Action parameters (v1). CDDL: actions.cddl parameters-map.", + "properties": { + "ingredient": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the ingredient assertion this action acts on" + }, + "description": { + "type": "string", + "description": "Additional description of the action" + } + }, + "additionalProperties": true + }, + "actionsAssertionV2": { + "type": "object", + "description": "Actions assertion (v2) (c2pa.actions.v2). CDDL: actions.cddl actions-map-v2.", + "properties": { + "actions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionItemV2" + }, + "description": "List of actions (action-item-map-v2)" + }, + "templates": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionTemplateV2" + }, + "description": "List of templates for the actions" + }, + "softwareAgents": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softwareAgent" + }, + "description": "List of software/hardware that performed the actions (generator-info maps)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + }, + "allActionsIncluded": { + "type": "boolean", + "description": "If true, indicates that all actions performed are included in this assertion" + } + }, + "required": [ + "actions" + ], + "additionalProperties": false + }, + "actionItemV2": { + "type": "object", + "description": "Single action (v2). CDDL: actions.cddl action-item-map-v2.", + "properties": { + "action": { + "type": "string", + "description": "Action type; $action-choice" + }, + "softwareAgent": { + "$ref": "#/definitions/softwareAgent", + "description": "Description of the software/hardware that did the action (generator-info map)" + }, + "softwareAgentIndex": { + "type": "integer", + "description": "0-based index into the softwareAgents array in the actions assertion" + }, + "description": { + "type": "string", + "description": "Additional description of the action, important for custom actions" + }, + "digitalSourceType": { + "type": "string", + "description": "One of the defined source types at https://cv.iptc.org/newscodes/digitalsourcetype/" + }, + "when": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the action occurred (tdate)" + }, + "changes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/regionMap" + }, + "description": "Regions of interest of the resource that were changed" + }, + "related": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionItemV2" + }, + "description": "List of related actions" + }, + "reason": { + "type": "string", + "description": "Reason the action was performed (required when action is c2pa.redacted); $action-reason" + }, + "parameters": { + "$ref": "#/definitions/parametersMapV2", + "description": "Additional parameters of the action" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "actionTemplateV2": { + "type": "object", + "description": "Action template (v2). CDDL: actions.cddl action-template-map-v2.", + "properties": { + "action": { + "type": "string", + "description": "Action type or \"*\" for templates; $action-choice / \"*\"" + }, + "softwareAgent": { + "$ref": "#/definitions/softwareAgent", + "description": "Description of the software/hardware (generator-info map)" + }, + "softwareAgentIndex": { + "type": "integer", + "description": "0-based index into the softwareAgents array" + }, + "description": { + "type": "string", + "description": "Additional description of the action" + }, + "digitalSourceType": { + "type": "string", + "description": "Digital source type URI" + }, + "icon": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to an embedded data assertion" + }, + "templateParameters": { + "type": "object", + "description": "Additional parameters of the template (parameters-common-map-v2)", + "additionalProperties": true + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "regionMap": { + "type": "object", + "description": "Region of interest. CDDL: regions-of-interest.cddl region-map.", + "additionalProperties": true + }, + "parametersMapV2": { + "type": "object", + "description": "Action parameters (v2). CDDL: actions.cddl parameters-map-v2.", + "properties": { + "redacted": { + "type": "string", + "description": "JUMBF URI to the redacted assertion (required when action is c2pa.redacted)" + }, + "ingredients": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "List of hashed JUMBF URIs to ingredient (v2 or v3) assertion(s) this action acts on" + }, + "sourceLanguage": { + "type": "string", + "description": "BCP-47 code of the source language for c2pa.translated" + }, + "targetLanguage": { + "type": "string", + "description": "BCP-47 code of the target language for c2pa.translated" + }, + "multipleInstances": { + "type": "boolean", + "description": "Whether this action was performed multiple times" + } + }, + "additionalProperties": true + }, + "softwareAgent": { + "type": "object", + "description": "Generator/software agent info. CDDL: claim.cddl generator-info-map.", + "properties": { + "name": { + "type": "string", + "description": "Name of the software" + }, + "version": { + "type": "string", + "description": "Version of the software" + }, + "icon": { + "$ref": "#/definitions/hashedUriMap", + "description": "Icon representing the software/hardware" + }, + "operating_system": { + "type": "string", + "description": "Operating system the software runs on" + } + }, + "required": [ + "name" + ], + "additionalProperties": true + }, + "certificateInfo": { + "type": "object", + "description": "Certificate details (serial number, issuer, subject, validity). CrJSON decoding of signing certificate; no CDDL rule in INTERNAL/cddl.", + "properties": { + "serialNumber": { + "type": "string", + "description": "Certificate serial number" + }, + "issuer": { + "$ref": "#/definitions/distinguishedName" + }, + "subject": { + "$ref": "#/definitions/distinguishedName" + }, + "validity": { + "type": "object", + "properties": { + "notBefore": { + "type": "string", + "format": "date-time" + }, + "notAfter": { + "type": "string", + "format": "date-time" + } + } + } + }, + "required": [ + "serialNumber", + "issuer", + "subject", + "validity" + ], + "additionalProperties": false + }, + "signature": { + "type": "object", + "description": "Decoded claim signature (algorithm, certificate, optional timestamp). CrJSON decoding of Claim Signature box; no CDDL rule in INTERNAL/cddl.", + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm used for signing (e.g., SHA256withECDSA)" + }, + "certificateInfo": { + "$ref": "#/definitions/certificateInfo" + }, + "timeStampInfo": { + "type": "object", + "description": "Timestamp information (e.g. from TSA), when present", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Time when the claim was signed (e.g. from TSA timestamp); RFC 3339 format" + }, + "certificateInfo": { + "$ref": "#/definitions/certificateInfo" + } + }, + "required": [ + "timestamp", + "certificateInfo" + ] + } + }, + "required": [ + "algorithm", + "certificateInfo" + ], + "additionalProperties": false + }, + "distinguishedName": { + "type": "object", + "description": "X.509 Distinguished Name components. Used in signature/certificate decoding; no CDDL rule in INTERNAL/cddl.", + "properties": { + "C": { + "type": "string", + "description": "Country" + }, + "ST": { + "type": "string", + "description": "State or province" + }, + "L": { + "type": "string", + "description": "Locality" + }, + "O": { + "type": "string", + "description": "Organization" + }, + "OU": { + "type": "string", + "description": "Organizational unit" + }, + "CN": { + "type": "string", + "description": "Common name" + }, + "E": { + "type": "string", + "description": "Email address" + }, + "2.5.4.97": { + "type": "string", + "description": "Organization identifier OID" + } + }, + "additionalProperties": false + }, + "status": { + "type": "object", + "description": "Validation status information (signature, assertion, content, trust). CrJSON manifest-level status; validation codes use validation-results.cddl status-map / status-codes-map.", + "properties": { + "signature": { + "type": "string", + "description": "Signature validation status" + }, + "assertion": { + "type": "object", + "description": "Status of each assertion", + "additionalProperties": { + "type": "string" + } + }, + "content": { + "type": "string", + "description": "Content validation status" + }, + "trust": { + "type": "string", + "description": "Trust validation status" + } + }, + "additionalProperties": false + }, + "ingredientAssertionV1": { + "type": "object", + "description": "Ingredient assertion (v1) (c2pa.ingredient). CDDL: ingredient.cddl ingredient-map.", + "required": [ + "dc:title", + "dc:format", + "instanceID", + "relationship" + ], + "properties": { + "dc:title": { + "type": "string", + "description": "Name of the ingredient (Dublin Core)" + }, + "dc:format": { + "type": "string", + "description": "Media type of the ingredient (format-string)" + }, + "documentID": { + "type": "string", + "description": "Value of the ingredient's xmpMM:DocumentID" + }, + "instanceID": { + "type": "string", + "description": "Unique identifier, e.g. value of the ingredient's xmpMM:InstanceID" + }, + "relationship": { + "type": "string", + "description": "Relationship of this ingredient to the asset; $relation-choice (parentOf, componentOf, inputTo)" + }, + "c2pa_manifest": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to the C2PA Manifest of the ingredient" + }, + "thumbnail": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to an ingredient thumbnail" + }, + "validationStatus": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Status map per C2PA CDDL $status-map", + "additionalProperties": true + }, + "description": "Validation status of the ingredient" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "ingredientAssertionV2": { + "type": "object", + "description": "Ingredient assertion (v2) (c2pa.ingredient.v2). CDDL: ingredient.cddl ingredient-map-v2.", + "required": [ + "dc:title", + "dc:format", + "relationship" + ], + "properties": { + "dc:title": { + "type": "string", + "description": "Name of the ingredient (Dublin Core)" + }, + "dc:format": { + "type": "string", + "description": "Media type of the ingredient (format-string)" + }, + "relationship": { + "type": "string", + "description": "Relationship of this ingredient to the asset; $relation-choice" + }, + "documentID": { + "type": "string", + "description": "Value of the ingredient's xmpMM:DocumentID" + }, + "instanceID": { + "type": "string", + "description": "Unique identifier, e.g. value of the ingredient's xmpMM:InstanceID" + }, + "data": { + "description": "Hashed URI to embedded data assertion or hashed_ext_uri to external data", + "oneOf": [ + { + "$ref": "#/definitions/hashedUriMap" + }, + { + "$ref": "#/definitions/hashedExtUriMap" + } + ] + }, + "data_types": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Asset type per C2PA CDDL $asset-type-map", + "additionalProperties": true + }, + "description": "Additional information about the data's type" + }, + "c2pa_manifest": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to the C2PA Manifest of the ingredient" + }, + "thumbnail": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to a thumbnail in an embedded data assertion" + }, + "validationStatus": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Status map per C2PA CDDL $status-map", + "additionalProperties": true + }, + "description": "Validation status of the ingredient" + }, + "description": { + "type": "string", + "description": "Additional description of the ingredient" + }, + "informational_URI": { + "type": "string", + "description": "URI to an informational page about the ingredient or its data" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "ingredientAssertionV3": { + "type": "object", + "description": "Ingredient assertion (v3) (c2pa.ingredient.v3). CDDL: ingredient.cddl ingredient-map-v3.", + "required": [ + "relationship" + ], + "properties": { + "dc:title": { + "type": "string", + "description": "Name of the ingredient (Dublin Core)" + }, + "dc:format": { + "type": "string", + "description": "Media type of the ingredient (format-string)" + }, + "relationship": { + "type": "string", + "description": "Relationship of this ingredient to the asset; $relation-choice" + }, + "validationResults": { + "$ref": "#/definitions/validationResults", + "description": "Results from the claim generator performing full validation on the ingredient asset ($validation-results-map)" + }, + "instanceID": { + "type": "string", + "description": "Unique identifier, e.g. value of the ingredient's xmpMM:InstanceID" + }, + "data": { + "description": "Hashed URI to embedded data assertion or hashed_ext_uri to external data", + "oneOf": [ + { + "$ref": "#/definitions/hashedUriMap" + }, + { + "$ref": "#/definitions/hashedExtUriMap" + } + ] + }, + "dataTypes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Asset type per C2PA CDDL $asset-type-map", + "additionalProperties": true + }, + "description": "Additional information about the data's type (v3 camelCase)" + }, + "activeManifest": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the box corresponding to the active manifest of the ingredient" + }, + "claimSignature": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the Claim Signature box in the C2PA Manifest of the ingredient" + }, + "thumbnail": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to a thumbnail in an embedded data assertion" + }, + "description": { + "type": "string", + "description": "Additional description of the ingredient" + }, + "informationalURI": { + "type": "string", + "description": "URI to an informational page about the ingredient or its data (v3 camelCase)" + }, + "softBindingsMatched": { + "type": "boolean", + "description": "Whether soft bindings were matched" + }, + "softBindingAlgorithmsMatched": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "Array of algorithm names used for discovering the active manifest" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "hashedExtUriMap": { + "type": "object", + "description": "Reference to an external URL and its hash. CDDL: hashed-ext-uri.cddl $hashed-ext-uri-map. Used in ingredient v2/v3 data field.", + "properties": { + "url": { + "type": "string", + "description": "HTTP/HTTPS URI reference to external data", + "format": "uri" + }, + "alg": { + "type": "string", + "description": "String identifying the cryptographic hash algorithm used to compute the hash on this URI's data, taken from the C2PA hash algorithm identifier list." + }, + "hash": { + "type": "string", + "description": "Base64-encoded hash value of the external data" + }, + "dc:format": { + "type": "string", + "description": "IANA media type of the data (optional)" + }, + "size": { + "type": "integer", + "description": "Number of bytes of data (optional)" + }, + "data_types": { + "type": "array", + "description": "Additional information about the data's type (optional)", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "required": [ + "url", + "alg", + "hash" + ], + "additionalProperties": false + }, + "thumbnailAssertion": { + "type": "object", + "description": "Thumbnail assertion (e.g. c2pa.thumbnail.claim.jpeg, c2pa.thumbnail.ingredient.jpeg). No CDDL in INTERNAL/cddl; structure is CrJSON/schema-defined.", + "properties": { + "thumbnailType": { + "type": "integer", + "description": "Thumbnail type (0=claim, 1=ingredient)" + }, + "mimeType": { + "type": "string", + "description": "MIME type of thumbnail" + } + }, + "additionalProperties": false + }, + "bmffExclusionsMap": { + "type": "object", + "description": "Exclusion entry for BMFF hash. CDDL: bmff-hash.cddl exclusions-map.", + "required": [ + "xpath" + ], + "properties": { + "xpath": { + "type": "string", + "description": "Location of box(es) to exclude (XPath from root node)" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Length that a leafmost box must have to be excluded" + }, + "data": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/bmffDataMap" + }, + "description": "Data at relative byte offset that must match for box to be excluded" + }, + "subset": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/bmffSubsetMap" + }, + "description": "Portion of the excluded box that is excluded from the hash" + }, + "version": { + "type": "integer", + "description": "Version that must be set in a leafmost FullBox for the box to be excluded" + }, + "flags": { + "type": "string", + "description": "24-bit flags (3-byte byte string, e.g. Base64-encoded in JSON). Only for FullBox when version is specified." + }, + "exact": { + "type": "boolean", + "description": "If true, flags must be an exact match (default true). Only for FullBox when flags is specified." + } + }, + "additionalProperties": false + }, + "bmffDataMap": { + "type": "object", + "description": "CDDL: bmff-hash.cddl data-map.", + "required": [ + "offset", + "value" + ], + "properties": { + "offset": { + "type": "integer", + "minimum": 0, + "description": "Relative byte offset in the leafmost box" + }, + "value": { + "type": "string", + "description": "Byte string (e.g. Base64 in JSON) that must match at the offset" + } + }, + "additionalProperties": false + }, + "bmffSubsetMap": { + "type": "object", + "description": "CDDL: bmff-hash.cddl subset-map.", + "required": [ + "offset", + "length" + ], + "properties": { + "offset": { + "type": "integer", + "minimum": 0, + "description": "Relative byte offset within the excluded box" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Number of bytes in this subset" + } + }, + "additionalProperties": false + }, + "bmffMerkleMap": { + "type": "object", + "description": "Merkle tree row for mdat verification. CDDL: bmff-hash.cddl merkle-map.", + "required": [ + "uniqueId", + "localId", + "count", + "hashes" + ], + "properties": { + "uniqueId": { + "type": "integer", + "description": "1-based unique id to determine which Merkle tree validates a given mdat box" + }, + "localId": { + "type": "integer", + "description": "Local id indicating Merkle tree" + }, + "count": { + "type": "integer", + "description": "Number of leaf nodes in the Merkle tree (null nodes not included)" + }, + "alg": { + "type": "string", + "description": "Hash algorithm for this Merkle tree; if absent, from enclosing structure" + }, + "initHash": { + "type": "string", + "description": "Hash of init segment or bytes before first moof (fragmented MP4); absent for non-fragmented" + }, + "hashes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "Ordered array representing a single row of the Merkle tree (e.g. Base64 in JSON)" + }, + "fixedBlockSize": { + "type": "integer", + "minimum": 0, + "description": "Size in bytes of a leaf node (non-fragmented MP4, piecewise mdat validation)" + }, + "variableBlockSizes": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0 + }, + "description": "Size in bytes of each leaf node (non-fragmented MP4); length equals count" + } + }, + "additionalProperties": false + }, + "hashBmffAssertion": { + "type": "object", + "description": "BMFF hash assertion (c2pa.hash.bmff.v2). CDDL: bmff-hash.cddl bmff-hash-map, exclusions-map, merkle-map.", + "required": [ + "exclusions" + ], + "properties": { + "exclusions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/bmffExclusionsMap" + }, + "description": "Box(es) to exclude from the hash" + }, + "alg": { + "type": "string", + "description": "Hash algorithm; if absent, from enclosing structure (C2PA hash algorithm identifier list)" + }, + "hash": { + "type": "string", + "description": "Hash of BMFF/segment excluding exclusions (e.g. Base64 in JSON)" + }, + "merkle": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/bmffMerkleMap" + }, + "description": "Merkle tree rows for verification of mdat box(es) or fragment files" + }, + "name": { + "type": "string", + "description": "Human-readable description of what this hash covers" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Unused and deprecated" + }, + "sequenceNumber": { + "type": "integer", + "minimum": 0, + "description": "In a live video workflow, the monotonically increasing sequence number for each segment" + } + }, + "additionalProperties": false + }, + "validationInfo": { + "type": "object", + "description": "Validation information summary (signature codes, trust, content) and validation time. Document-level.", + "properties": { + "signature": { + "type": "array", + "description": "All status codes related to claim signature (claimSignature*)", + "items": { + "type": "string" + } + }, + "content": { + "type": "string", + "description": "Content validation status" + }, + "trust": { + "type": "string", + "description": "Trust validation status" + }, + "validationTime": { + "type": "string", + "format": "date-time", + "description": "Time when the validation was performed; RFC 3339 format" + } + }, + "additionalProperties": false + }, + "validationResults": { + "type": "object", + "description": "Validation results (active manifest and optional ingredient deltas). CDDL: validation-results.cddl validation-results-map. Used in ingredient assertions (e.g. c2pa.ingredient.v3).", + "properties": { + "activeManifest": { + "$ref": "#/definitions/statusCodes", + "description": "Validation status codes for the active manifest. Present when ingredient is a C2PA asset." + }, + "ingredientDeltas": { + "type": "array", + "description": "Validation deltas per ingredient assertion. Present when the ingredient is a C2PA asset.", + "items": { + "$ref": "#/definitions/ingredientDeltaValidationResult" + } + }, + "specVersion": { + "type": "string", + "description": "The version of the specification against which the validation was performed (SemVer formatted string)", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "trustListUri": { + "type": "string", + "format": "uri", + "description": "URI to the trust list that was used to validate certificates" + } + }, + "required": [ + "activeManifest" + ], + "additionalProperties": false + }, + "statusCodes": { + "type": "object", + "description": "Success, informational, and failure validation status codes. CDDL: validation-results.cddl status-codes-map.", + "properties": { + "success": { + "type": "array", + "items": { + "$ref": "#/definitions/validationStatusEntry" + } + }, + "informational": { + "type": "array", + "items": { + "$ref": "#/definitions/validationStatusEntry" + } + }, + "failure": { + "type": "array", + "items": { + "$ref": "#/definitions/validationStatusEntry" + } + } + }, + "required": [ + "success", + "informational", + "failure" + ], + "additionalProperties": false + }, + "validationStatusEntry": { + "type": "object", + "description": "Single validation status (code, optional url and explanation). CDDL: validation-results.cddl status-map.", + "properties": { + "code": { + "type": "string", + "description": "Validation status code" + }, + "url": { + "type": "string", + "description": "JUMBF URI where status applies" + }, + "explanation": { + "type": "string", + "description": "Human-readable explanation" + } + }, + "required": [ + "code" + ], + "additionalProperties": false + }, + "ingredientDeltaValidationResult": { + "type": "object", + "description": "Validation deltas for one ingredient's manifest. CDDL: validation-results.cddl ingredient-delta-validation-result-map.", + "properties": { + "ingredientAssertionURI": { + "type": "string", + "description": "JUMBF URI to the ingredient assertion" + }, + "validationDeltas": { + "$ref": "#/definitions/statusCodes" + } + }, + "required": [ + "ingredientAssertionURI", + "validationDeltas" + ], + "additionalProperties": false + } + } +} diff --git a/sdk/src/claim_generator_info.rs b/sdk/src/claim_generator_info.rs index be5e459d2..c466475cc 100644 --- a/sdk/src/claim_generator_info.rs +++ b/sdk/src/claim_generator_info.rs @@ -34,8 +34,9 @@ pub struct ClaimGeneratorInfo { /// hashed URI to the icon (either embedded or remote) #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, - /// A human readable string of the OS the claim generator is running on - #[serde(skip_serializing_if = "Option::is_none")] + /// A human readable string of the OS the claim generator is running on. + /// CrJSON schema uses `operating_system`; C2PA CBOR may use `schema.org.SoftwareApplication.operatingSystem`. + #[serde(alias = "schema.org.SoftwareApplication.operatingSystem", skip_serializing_if = "Option::is_none")] pub operating_system: Option, // Any other values that are not part of the standard #[serde(flatten)] diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs new file mode 100644 index 000000000..a7f02c97d --- /dev/null +++ b/sdk/src/cr_json_reader.rs @@ -0,0 +1,1711 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +//! crJSON format exporter for C2PA manifests. +//! +//! This module provides a Reader-like API that exports C2PA manifests in the +//! crJSON format as described in the crJSON specification. + +use std::{ + io::{Read, Seek}, + sync::Arc, +}; + +use async_generic::async_generic; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use serde_with::skip_serializing_none; +use x509_parser::{certificate::X509Certificate, prelude::FromDer}; + +use crate::{ + assertion::AssertionData, + claim::Claim, + context::Context, + crypto::{ + base64, + cose::{parse_cose_sign1, timestamp_token_bytes_from_sign1}, + time_stamp::tsa_signer_cert_der_from_token, + }, + error::{Error, Result}, + jumbf::labels::{manifest_label_from_uri, to_absolute_uri}, + reader::{AsyncPostValidator, MaybeSend, PostValidator, Reader}, + status_tracker::StatusTracker, + validation_results::{ + validation_codes::{ + SIGNING_CREDENTIAL_EXPIRED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_TRUSTED, + SIGNING_CREDENTIAL_UNTRUSTED, + }, + StatusCodes, + ValidationState, + }, + validation_status::ValidationStatus, + Manifest, +}; + +/// Use a CrJsonReader to read and validate a manifest store in crJSON format. +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Default)] +pub struct CrJsonReader { + #[serde(skip)] + inner: Reader, +} + +impl CrJsonReader { + /// Create a new CrJsonReader with the given [`Context`]. + pub fn from_context(context: Context) -> Self { + Self { + inner: Reader::from_context(context), + } + } + + /// Create a new CrJsonReader with a shared [`Context`]. + pub fn from_shared_context(context: &Arc) -> Self { + Self { + inner: Reader::from_shared_context(context), + } + } + + /// Add manifest store from a stream to the [`CrJsonReader`] + #[async_generic] + pub fn with_stream( + mut self, + format: &str, + stream: impl Read + Seek + MaybeSend, + ) -> Result { + if _sync { + self.inner = self.inner.with_stream(format, stream)?; + } else { + self.inner = self.inner.with_stream_async(format, stream).await?; + } + Ok(self) + } + + /// Create a crJSON format [`CrJsonReader`] from a stream. + #[async_generic] + pub fn from_stream(format: &str, stream: impl Read + Seek + MaybeSend) -> Result { + if _sync { + Ok(Self { + inner: Reader::from_stream(format, stream)?, + }) + } else { + Ok(Self { + inner: Reader::from_stream_async(format, stream).await?, + }) + } + } + + /// Add manifest store from a file to the [`CrJsonReader`]. + #[cfg(feature = "file_io")] + #[async_generic] + pub fn with_file>(mut self, path: P) -> Result { + if _sync { + self.inner = self.inner.with_file(path)?; + } else { + self.inner = self.inner.with_file_async(path).await?; + } + Ok(self) + } + + /// Create a crJSON format [`CrJsonReader`] from a file. + #[cfg(feature = "file_io")] + #[async_generic] + pub fn from_file>(path: P) -> Result { + if _sync { + Ok(Self { + inner: Reader::from_file(path)?, + }) + } else { + Ok(Self { + inner: Reader::from_file_async(path).await?, + }) + } + } + + /// Add manifest store from existing `c2pa_data` and a stream to the [`CrJsonReader`]. + #[async_generic] + pub fn with_manifest_data_and_stream( + mut self, + c2pa_data: &[u8], + format: &str, + stream: impl Read + Seek + MaybeSend, + ) -> Result { + if _sync { + self.inner = self + .inner + .with_manifest_data_and_stream(c2pa_data, format, stream)?; + } else { + self.inner = self + .inner + .with_manifest_data_and_stream_async(c2pa_data, format, stream) + .await?; + } + Ok(self) + } + + /// Create a crJSON format [`CrJsonReader`] from existing `c2pa_data` and a stream. + #[async_generic] + pub fn from_manifest_data_and_stream( + c2pa_data: &[u8], + format: &str, + stream: impl Read + Seek + MaybeSend, + ) -> Result { + if _sync { + Ok(Self { + inner: Reader::from_manifest_data_and_stream(c2pa_data, format, stream)?, + }) + } else { + Ok(Self { + inner: Reader::from_manifest_data_and_stream_async(c2pa_data, format, stream) + .await?, + }) + } + } + + /// Post-validate the reader + #[async_generic(async_signature( + &mut self, + validator: &impl AsyncPostValidator + ))] + pub fn post_validate(&mut self, validator: &impl PostValidator) -> Result<()> { + if _sync { + self.inner.post_validate(validator) + } else { + self.inner.post_validate_async(validator).await + } + } + + /// Returns the remote url of the manifest if this [`CrJsonReader`] obtained the manifest remotely. + pub fn remote_url(&self) -> Option<&str> { + self.inner.remote_url() + } + + /// Returns if the [`CrJsonReader`] was created from an embedded manifest. + pub fn is_embedded(&self) -> bool { + self.inner.is_embedded() + } + + /// Get the [`ValidationState`] of the manifest store. + pub fn validation_state(&self) -> ValidationState { + self.inner.validation_state() + } + + /// Convert the reader to a crJSON format JSON value. + pub fn to_json_value(&self) -> Result { + let mut result = json!({ + "@context": { + "@vocab": "https://contentcredentials.org/crjson", + "extras": "https://contentcredentials.org/crjson/extras" + } + }); + + // Convert manifests from HashMap to Array + let manifests_array = self.convert_manifests_to_array()?; + result["manifests"] = manifests_array; + + // Add document-level validationInfo (summary + validationTime). ingredientDeltas are per-manifest. + if let Some(validation_results) = self.inner.validation_results() { + if let Some(vs) = self.build_validation_info(validation_results)? { + result["validationInfo"] = vs; + } + } + + // jsonGenerator: claim_generator_info fields from the (active) manifest + date (ISO 8601). + result["jsonGenerator"] = self.build_json_generator()?; + + Ok(result) + } + + /// Build the root-level jsonGenerator object: the CrJSON is produced by c2pa-rs (this library). + fn build_json_generator(&self) -> Result { + Ok(json!({ + "name": "c2pa-rs", + "version": env!("CARGO_PKG_VERSION"), + "date": Utc::now().to_rfc3339() + })) + } + + /// Get the CrJsonReader as a JSON string + pub fn json(&self) -> String { + match self.to_json_value() { + Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_default(), + Err(_) => "{}".to_string(), + } + } + + /// Convert manifests from HashMap to Array format. + /// The first element is always the active manifest (if any); the rest are in manifest store order (most recent first, earliest last). + fn convert_manifests_to_array(&self) -> Result { + let active_label = self.inner.active_label(); + let mut labeled: Vec<(String, Value)> = Vec::new(); + + for (label, manifest) in self.inner.manifests() { + let mut manifest_obj = Map::new(); + manifest_obj.insert("label".to_string(), json!(label)); + + // Convert assertions from array to object + let assertions_obj = self.convert_assertions(manifest, label)?; + manifest_obj.insert("assertions".to_string(), json!(assertions_obj)); + + // Emit claim (v1) or claim.v2 per crJSON schema oneOf, based on source claim version + let claim = self + .inner + .store + .get_claim(label) + .ok_or_else(|| Error::ClaimMissing { label: label.to_owned() })?; + if claim.version() == 1 { + let claim_v1 = self.build_claim_v1(manifest, label, claim)?; + manifest_obj.insert("claim".to_string(), claim_v1); + } else { + let claim_v2 = self.build_claim_v2(manifest, label)?; + manifest_obj.insert("claim.v2".to_string(), claim_v2); + } + + // Build signature object (required per manifest schema; use empty object when no signature info) + let claim_ref = self.inner.store.get_claim(label); + let signature = self + .build_claim_signature(manifest, claim_ref)? + .unwrap_or_else(|| Value::Object(Map::new())); + manifest_obj.insert("signature".to_string(), signature); + + // Build validationResults (statusCodes) for this manifest (required; use empty success/informational/failure when none) + let validation_results = self.build_manifest_validation_results(label); + manifest_obj.insert("validationResults".to_string(), validation_results); + + // Per-manifest ingredientDeltas: deltas whose ingredientAssertionURI belongs to this manifest + if let Some(deltas) = self.build_manifest_ingredient_deltas(label) { + manifest_obj.insert("ingredientDeltas".to_string(), deltas); + } + + labeled.push((label.clone(), Value::Object(manifest_obj))); + } + + // Order: active manifest first, then others by manifest store order (most recent first) + let store_order: std::collections::HashMap = self + .inner + .store + .claims() + .into_iter() + .enumerate() + .map(|(i, claim)| (claim.label().to_string(), i)) + .collect(); + labeled.sort_by(|a, b| { + let a_active = active_label.map_or(false, |l| l == a.0); + let b_active = active_label.map_or(false, |l| l == b.0); + match (a_active, b_active) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => { + let a_idx = store_order.get(&a.0).copied().unwrap_or(0); + let b_idx = store_order.get(&b.0).copied().unwrap_or(0); + b_idx.cmp(&a_idx) // descending: most recent (higher index) first + } + } + }); + + let manifests_array = labeled.into_iter().map(|(_, v)| v).collect(); + Ok(Value::Array(manifests_array)) + } + + /// Convert assertions from array format to object format (keyed by label) + fn convert_assertions(&self, manifest: &Manifest, manifest_label: &str) -> Result> { + let mut assertions_obj = Map::new(); + + // Process regular assertions + for assertion in manifest.assertions() { + let label = assertion.label().to_string(); + + // Try to get the value - if it fails (e.g., binary or CBOR), try to decode it + let value_result = if let Ok(value) = assertion.value() { + Ok(value.clone()) + } else { + // For CBOR assertions (like ingredients), try to decode them + self.decode_assertion_data(assertion) + }; + + if let Ok(value) = value_result { + // Fix any byte array hashes to base64 strings + let fixed_value = Self::fix_hash_encoding(value); + assertions_obj.insert(label, fixed_value); + } + } + + // Add hash assertions (c2pa.hash.data, c2pa.hash.bmff, c2pa.hash.boxes) + // These are filtered out by Manifest::from_store but we need them for crJSON format + if let Some(claim) = self.inner.store.get_claim(manifest_label) { + for hash_assertion in claim.hash_assertions() { + let label = hash_assertion.label_raw(); + let instance = hash_assertion.instance(); + + // Get the assertion and convert to JSON + if let Some(assertion) = claim.get_claim_assertion(&label, instance) { + if let Ok(assertion_obj) = assertion.assertion().as_json_object() { + let fixed_value = Self::fix_hash_encoding(assertion_obj); + + // Handle instance numbers for multiple assertions with same label + let final_label = if instance > 0 { + format!("{}_{}", label, instance + 1) + } else { + label + }; + + assertions_obj.insert(final_label, fixed_value); + } + } + } + } + + // Add ingredient assertions from the ingredients array + // Each ingredient is itself an assertion that should be in the assertions object + for (index, ingredient) in manifest.ingredients().iter().enumerate() { + // Convert ingredient to JSON + if let Ok(ingredient_json) = serde_json::to_value(ingredient) { + // Fix any byte array hashes to base64 strings + let mut fixed_ingredient = Self::fix_hash_encoding(ingredient_json); + + // Get the label from the ingredient (includes version if v2+) + // The label field contains the correct versioned label like "c2pa.ingredient.v2" + let base_label = if let Some(label_value) = fixed_ingredient.get("label") { + label_value + .as_str() + .unwrap_or("c2pa.ingredient") + .to_string() + } else { + "c2pa.ingredient".to_string() + }; + + // Remove the label field since it's redundant (the label is the key in assertions object) + if let Some(obj) = fixed_ingredient.as_object_mut() { + obj.remove("label"); + } + + // Add instance number if there are multiple ingredients + let label = if manifest.ingredients().len() > 1 { + format!("{}__{}", base_label, index + 1) + } else { + base_label + }; + + assertions_obj.insert(label, fixed_ingredient); + } + } + + // Add gathered assertions (e.g. c2pa.icon) that are not in manifest.assertions(). + // Manifest::from_store skips binary assertions like icon (AssertionData::Binary => {}), + // so they must be added from the claim's gathered_assertions for crJSON output. + if let Some(claim) = self.inner.store.get_claim(manifest_label) { + if let Some(gathered) = claim.gathered_assertions() { + for assertion_ref in gathered { + let (label, instance) = Claim::assertion_label_from_link(&assertion_ref.url()); + let final_label = if instance > 0 { + format!("{}_{}", label, instance + 1) + } else { + label.clone() + }; + if assertions_obj.contains_key(&final_label) { + continue; + } + if let Some(claim_assertion) = claim.get_claim_assertion(&label, instance) { + let assertion = claim_assertion.assertion(); + // Binary/Uuid assertions (e.g. c2pa.icon EmbeddedData): emit format, identifier, hash + // instead of raw bytes so the output is usable and matches claim_generator_info icon refs + let use_ref_format = matches!( + assertion.decode_data(), + AssertionData::Binary(_) | AssertionData::Uuid(_, _) + ); + if use_ref_format { + let absolute_uri = + to_absolute_uri(manifest_label, &assertion_ref.url()); + let mut ref_obj = Map::new(); + ref_obj.insert( + "format".to_string(), + json!(assertion.content_type()), + ); + ref_obj.insert("identifier".to_string(), json!(absolute_uri)); + ref_obj.insert( + "hash".to_string(), + json!(base64::encode(&assertion_ref.hash())), + ); + assertions_obj.insert(final_label, Value::Object(ref_obj)); + } else if let Ok(assertion_obj) = assertion.as_json_object() { + let fixed_value = Self::fix_hash_encoding(assertion_obj); + assertions_obj.insert(final_label, fixed_value); + } + } + } + } + } + + Ok(assertions_obj) + } + + /// Decode assertion data that's not in JSON format (e.g., CBOR) + fn decode_assertion_data(&self, assertion: &crate::ManifestAssertion) -> Result { + // Try to get binary data and decode as CBOR + if let Ok(binary) = assertion.binary() { + // Try to decode as CBOR to JSON + let cbor_value: c2pa_cbor::Value = c2pa_cbor::from_slice(binary)?; + let json_value: Value = serde_json::to_value(&cbor_value)?; + Ok(json_value) + } else { + Err(Error::UnsupportedType) + } + } + + /// Recursively convert byte array hashes and pads to base64 strings + /// + /// This fixes the issue where hash and pad fields are serialized as byte arrays + /// instead of base64 strings when converting from CBOR to JSON. + fn fix_hash_encoding(value: Value) -> Value { + match value { + Value::Object(mut map) => { + // Normalize softwareAgent to crJSON schema: use operating_system instead of schema.org.SoftwareApplication.operatingSystem + const SCHEMA_ORG_OS_KEY: &str = "schema.org.SoftwareApplication.operatingSystem"; + if let Some(os_value) = map.remove(SCHEMA_ORG_OS_KEY) { + if !map.contains_key("operating_system") { + map.insert("operating_system".to_string(), os_value); + } + } + + // Check if this object has a "hash" field that's an array + if let Some(hash_value) = map.get("hash") { + if let Some(hash_array) = hash_value.as_array() { + // Check if it's an array of integers (byte array) + if hash_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = hash_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let hash_b64 = base64::encode(&bytes); + map.insert("hash".to_string(), json!(hash_b64)); + } + } + } + + // Check if this object has a "pad" field that's an array + if let Some(pad_value) = map.get("pad") { + if let Some(pad_array) = pad_value.as_array() { + // Check if it's an array of integers (byte array) + if pad_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = pad_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let pad_b64 = base64::encode(&bytes); + map.insert("pad".to_string(), json!(pad_b64)); + } + } + } + + // Check if this object has a "pad1" field that's an array (cawg.identity) + if let Some(pad1_value) = map.get("pad1") { + if let Some(pad1_array) = pad1_value.as_array() { + // Check if it's an array of integers (byte array) + if pad1_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = pad1_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let pad1_b64 = base64::encode(&bytes); + map.insert("pad1".to_string(), json!(pad1_b64)); + } + } + } + + // Check if this object has a "pad2" field that's an array (cawg.identity) + if let Some(pad2_value) = map.get("pad2") { + if let Some(pad2_array) = pad2_value.as_array() { + // Check if it's an array of integers (byte array) + if pad2_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = pad2_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let pad2_b64 = base64::encode(&bytes); + map.insert("pad2".to_string(), json!(pad2_b64)); + } + } + } + + // Per crJSON hashDataAssertion schema: "hash" and "pad" are required. Ensure "pad" is present when "hash" is. + if map.contains_key("hash") && !map.contains_key("pad") { + map.insert("pad".to_string(), json!("")); + } + + // Check if this object has a "signature" field that's an array (cawg.identity) + // This should be decoded as COSE_Sign1 and expanded similar to claimSignature + if let Some(signature_value) = map.get("signature") { + if let Some(sig_array) = signature_value.as_array() { + // Check if it's an array of integers (byte array) + if sig_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let sig_bytes: Vec = sig_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Try to decode as COSE_Sign1 and extract certificate info + if let Ok(decoded_sig) = Self::decode_cawg_signature(&sig_bytes) { + map.insert("signature".to_string(), decoded_sig); + } else { + // If decoding fails, fall back to base64 + let sig_b64 = base64::encode(&sig_bytes); + map.insert("signature".to_string(), json!(sig_b64)); + } + } + } + } + + // Recursively process all values in the map + for (_key, val) in map.iter_mut() { + *val = Self::fix_hash_encoding(val.clone()); + } + + // Normalize "icon" fields to crJSON hashedUriMap shape (url, hash, optional alg) only. + // Schema hashedUriMap has additionalProperties: false; do not emit format/identifier. + if let Some(icon_val) = map.get_mut("icon") { + if let Some(icon_obj) = icon_val.as_object_mut() { + if !icon_obj.contains_key("url") { + if let Some(id_val) = icon_obj.get("identifier") { + if let Some(id_str) = id_val.as_str() { + icon_obj.insert("url".to_string(), json!(id_str)); + } + } + } + Self::icon_retain_only_hashed_uri_map_keys(icon_obj); + } + } + + Value::Object(map) + } + Value::Array(arr) => { + // Recursively process all array elements + Value::Array(arr.into_iter().map(Self::fix_hash_encoding).collect()) + } + other => other, + } + } + + /// Retain only hashedUriMap keys (url, hash, alg) on an icon object; remove format, identifier, data_types, etc. + fn icon_retain_only_hashed_uri_map_keys(icon_obj: &mut Map) { + const ALLOWED: &[&str] = &["url", "hash", "alg"]; + icon_obj.retain(|k, _| ALLOWED.contains(&k.as_str())); + } + + /// Resolve an icon value (ResourceRef with identifier, or already hashedUriMap) to schema + /// hashedUriMap { url, hash, alg? }. Returns Some(Value) when we can produce a valid hashedUriMap. + fn resolve_icon_to_hashed_uri_map(claim: &Claim, manifest_label: &str, icon_val: &Value) -> Option { + let icon_obj = icon_val.as_object()?; + let url_str = icon_obj.get("url").and_then(|v| v.as_str()); + let hash_str = icon_obj.get("hash").and_then(|v| v.as_str()); + let identifier_str = icon_obj.get("identifier").and_then(|v| v.as_str()); + // Already valid hashedUriMap: url and hash both present and hash is string + if url_str.is_some() && hash_str.is_some() { + return None; // keep as-is + } + // ResourceRef with hash preserved (from HashedUri): build hashedUriMap without claim lookup + if let (Some(uri), Some(hash)) = (identifier_str.or(url_str), hash_str) { + let mut map = Map::new(); + map.insert("url".to_string(), json!(uri)); + map.insert("hash".to_string(), json!(hash)); + if let Some(alg) = icon_obj.get("alg").and_then(|v| v.as_str()) { + map.insert("alg".to_string(), json!(alg)); + } + return Some(Value::Object(map)); + } + // Need to resolve from claim: get identifier or url, then get hash from claim + let uri = identifier_str.or(url_str)?; + let (label, instance) = Claim::assertion_label_from_link(uri); + // Prefer assertion_hashed_uri_from_label for correct relative URL; fallback to get_claim_assertion + identifier as url + let (url, hash_b64, alg) = if let Some((hashed_uri, _)) = + claim.assertion_hashed_uri_from_label(&Claim::label_with_instance(&label, instance)) + { + ( + to_absolute_uri(manifest_label, &hashed_uri.url()), + base64::encode(&hashed_uri.hash()), + hashed_uri.alg(), + ) + } else if let Some(ca) = claim.get_claim_assertion(&label, instance) { + // Use identifier/uri as url (already absolute when from ResourceRef) and hash from claim assertion + (uri.to_string(), base64::encode(ca.hash()), None) + } else { + return None; + }; + let mut map = Map::new(); + map.insert("url".to_string(), json!(url)); + map.insert("hash".to_string(), json!(hash_b64)); + if let Some(alg) = alg { + map.insert("alg".to_string(), json!(alg)); + } + Some(Value::Object(map)) + } + + /// Build the claim (v1) object per crJSON claimV1 / C2PA CDDL claim-map. + /// Used when the source claim is version 1 (has "assertions", not "created_assertions"). + fn build_claim_v1( + &self, + manifest: &Manifest, + label: &str, + claim: &Claim, + ) -> Result { + let mut claim_v1 = Map::new(); + + // Required: claim_generator (v1 only) + claim_v1.insert( + "claim_generator".to_string(), + json!(claim + .claim_generator() + .or_else(|| manifest.claim_generator()) + .unwrap_or("")), + ); + + // Required: claim_generator_info as array (v1 schema) + if let Some(ref info_vec) = manifest.claim_generator_info { + let mut info_array = Vec::new(); + for info in info_vec { + if let Ok(info_value) = serde_json::to_value(info) { + let fixed_info = Self::fix_hash_encoding(info_value); + let mut info_obj = match fixed_info { + Value::Object(m) => m, + _ => continue, + }; + if let Some(icon_val) = info_obj.get_mut("icon") { + if let Some(resolved) = + Self::resolve_icon_to_hashed_uri_map(claim, label, icon_val) + { + *icon_val = resolved; + } + if let Some(icon_obj) = icon_val.as_object_mut() { + Self::icon_retain_only_hashed_uri_map_keys(icon_obj); + } + } + info_array.push(Value::Object(info_obj)); + } + } + if !info_array.is_empty() { + claim_v1.insert("claim_generator_info".to_string(), Value::Array(info_array)); + } + } + if !claim_v1.contains_key("claim_generator_info") { + // Schema requires at least one; use minimal placeholder if missing + claim_v1.insert( + "claim_generator_info".to_string(), + json!([{"name": claim.claim_generator().unwrap_or("")}]), + ); + } + + // Required: signature (JUMBF URI reference) + let signature_ref = format!("self#jumbf=/c2pa/{}/c2pa.signature", label); + claim_v1.insert("signature".to_string(), json!(signature_ref)); + + // Required: assertions (array of hashedUriMap); v1 uses single list + let mut assertions_arr = Vec::new(); + for assertion_ref in claim.assertions() { + let mut ref_obj = Map::new(); + ref_obj.insert( + "url".to_string(), + json!(to_absolute_uri(label, &assertion_ref.url())), + ); + ref_obj.insert( + "hash".to_string(), + json!(base64::encode(&assertion_ref.hash())), + ); + if let Some(alg) = assertion_ref.alg() { + ref_obj.insert("alg".to_string(), json!(alg)); + } + assertions_arr.push(Value::Object(ref_obj)); + } + claim_v1.insert("assertions".to_string(), Value::Array(assertions_arr)); + + // Required: dc:format, instanceID + claim_v1.insert( + "dc:format".to_string(), + json!(manifest + .format() + .or_else(|| claim.format()) + .unwrap_or("")), + ); + claim_v1.insert("instanceID".to_string(), json!(manifest.instance_id())); + + // Optional v1 fields + let title_str = manifest + .title() + .or_else(|| claim.title().map(|s| s.as_str())); + if let Some(title) = title_str { + claim_v1.insert("dc:title".to_string(), json!(title)); + } + if let Some(redacted) = claim.redactions() { + if !redacted.is_empty() { + claim_v1.insert("redacted_assertions".to_string(), json!(redacted)); + } + } + claim_v1.insert("alg".to_string(), json!(claim.alg())); + if let Some(alg_soft) = claim.alg_soft() { + claim_v1.insert("alg_soft".to_string(), json!(alg_soft)); + } + if let Some(metadata) = claim.metadata() { + if !metadata.is_empty() { + if let Ok(v) = serde_json::to_value(metadata) { + claim_v1.insert("metadata".to_string(), v); + } + } + } + + Ok(Value::Object(claim_v1)) + } + + /// Build the claim.v2 object from scattered manifest properties + fn build_claim_v2(&self, manifest: &Manifest, label: &str) -> Result { + let mut claim_v2 = Map::new(); + + // Add dc:title + if let Some(title) = manifest.title() { + claim_v2.insert("dc:title".to_string(), json!(title)); + } + + // Add instanceID + claim_v2.insert("instanceID".to_string(), json!(manifest.instance_id())); + + // Add claim_generator (string, e.g. for V1) + if let Some(claim_generator) = manifest.claim_generator() { + claim_v2.insert("claim_generator".to_string(), json!(claim_generator)); + } + + // Add claim_generator_info (single object per claim.v2 schema, not array). + // claim.v2 has one generator-info map; use the first when present. + if let Some(ref info_vec) = manifest.claim_generator_info { + if let Some(first) = info_vec.first() { + if let Ok(info_value) = serde_json::to_value(first) { + let fixed_info = Self::fix_hash_encoding(info_value); + // Ensure we never emit an array: take first element if value is array (e.g. from alternate path) + let mut info_for_claim = match &fixed_info { + Value::Array(arr) if !arr.is_empty() => arr[0].clone(), + _ => fixed_info, + }; + // Resolve claim_generator_info icon to hashedUriMap (url, hash, alg?) per schema + if let Some(claim) = self.inner.store.get_claim(label) { + if let Some(info_obj) = info_for_claim.as_object_mut() { + if let Some(icon_val) = info_obj.get_mut("icon") { + if let Some(resolved) = + Self::resolve_icon_to_hashed_uri_map(claim, label, icon_val) + { + *icon_val = resolved; + } + } + } + } + claim_v2.insert("claim_generator_info".to_string(), info_for_claim); + } + } + } + // Per crJSON schema, claim.v2 requires claim_generator_info (softwareAgent with at least "name"). + if !claim_v2.contains_key("claim_generator_info") { + let fallback = manifest.claim_generator().map_or_else( + || json!({"name": "Unknown"}), + |cg| json!({"name": cg}), + ); + claim_v2.insert("claim_generator_info".to_string(), fallback); + } + + // Add algorithm (from data hash assertion if available) + claim_v2.insert("alg".to_string(), json!("SHA-256")); + + // Add signature reference + let signature_ref = format!("self#jumbf=/c2pa/{}/c2pa.signature", label); + claim_v2.insert("signature".to_string(), json!(signature_ref)); + + // Build created_assertions and gathered_assertions arrays with hashes from the underlying claim + let (created_assertions, gathered_assertions) = self.build_assertion_references(manifest, label)?; + claim_v2.insert("created_assertions".to_string(), created_assertions); + claim_v2.insert("gathered_assertions".to_string(), gathered_assertions); + + // Add empty array for redacted assertions + claim_v2.insert("redacted_assertions".to_string(), json!([])); + + // Per crJSON schema, claim.v2.claim_generator_info is a single object, never an array. + if let Some(existing) = claim_v2.get("claim_generator_info") { + if let Some(arr) = existing.as_array() { + if !arr.is_empty() { + claim_v2.insert("claim_generator_info".to_string(), arr[0].clone()); + } + } + } + + // Ensure claim_generator_info.icon is hashedUriMap (url, hash, alg?) when we can resolve it. + // Never remove icon: if present in CBOR it must appear in JSON. Output only hashedUriMap keys (no format/identifier). + if let Some(claim) = self.inner.store.get_claim(label) { + if let Some(cgi) = claim_v2.get_mut("claim_generator_info") { + if let Some(info_obj) = cgi.as_object_mut() { + if let Some(icon_val) = info_obj.get_mut("icon") { + let is_hashed_uri_map = icon_val + .as_object() + .and_then(|o| o.get("url").and_then(|v| v.as_str())) + .is_some() + && icon_val + .as_object() + .and_then(|o| o.get("hash").and_then(|v| v.as_str())) + .is_some(); + if !is_hashed_uri_map { + if let Some(resolved) = + Self::resolve_icon_to_hashed_uri_map(claim, label, icon_val) + { + *icon_val = resolved; + } + } + if let Some(icon_obj) = icon_val.as_object_mut() { + Self::icon_retain_only_hashed_uri_map_keys(icon_obj); + } + } + } + } + } + + Ok(Value::Object(claim_v2)) + } + + /// Build assertion references with hashes from manifest + /// Returns a tuple of (created_assertions, gathered_assertions) arrays + fn build_assertion_references(&self, _manifest: &Manifest, label: &str) -> Result<(Value, Value)> { + // Get the underlying claim to access created_assertions and gathered_assertions separately + let claim = self.inner.store.get_claim(label) + .ok_or_else(|| Error::ClaimMissing { label: label.to_owned() })?; + + // Build created_assertions array + let mut created_refs = Vec::new(); + for assertion_ref in claim.created_assertions() { + let mut ref_obj = Map::new(); + ref_obj.insert("url".to_string(), json!(assertion_ref.url())); + let hash = assertion_ref.hash(); + ref_obj.insert("hash".to_string(), json!(base64::encode(&hash))); + created_refs.push(Value::Object(ref_obj)); + } + + // Build gathered_assertions array if available + let mut gathered_refs = Vec::new(); + if let Some(gathered) = claim.gathered_assertions() { + for assertion_ref in gathered { + let mut ref_obj = Map::new(); + ref_obj.insert("url".to_string(), json!(assertion_ref.url())); + let hash = assertion_ref.hash(); + ref_obj.insert("hash".to_string(), json!(base64::encode(&hash))); + gathered_refs.push(Value::Object(ref_obj)); + } + } + + // For Claim V1, created_assertions and gathered_assertions will both be empty + // In that case, populate created_assertions with all assertions (V1 doesn't distinguish) + if created_refs.is_empty() && gathered_refs.is_empty() && claim.version() == 1 { + for assertion_ref in claim.assertions() { + let mut ref_obj = Map::new(); + ref_obj.insert("url".to_string(), json!(assertion_ref.url())); + let hash = assertion_ref.hash(); + ref_obj.insert("hash".to_string(), json!(base64::encode(&hash))); + created_refs.push(Value::Object(ref_obj)); + } + } + + Ok((Value::Array(created_refs), Value::Array(gathered_refs))) + } + + /// Build claim_signature object with detailed certificate information + fn build_claim_signature( + &self, + manifest: &Manifest, + claim: Option<&Claim>, + ) -> Result> { + let sig_info = match manifest.signature_info() { + Some(info) => info, + None => return Ok(None), + }; + + let mut claim_signature = Map::new(); + + // Add algorithm (required per schema when signature object is present) + let alg_str = sig_info + .alg + .as_ref() + .map_or_else(String::new, |a| a.to_string()); + claim_signature.insert("algorithm".to_string(), json!(alg_str)); + + // Parse certificate to get detailed DN components and validity (nested in certificateInfo). + // Schema requires signature object to have both algorithm and certificateInfo. + let mut cert_info_obj = Map::new(); + if let Some(cert_info) = self.parse_certificate(&sig_info.cert_chain)? { + if let Some(serial) = cert_info.serial_number { + cert_info_obj.insert("serialNumber".to_string(), json!(serial)); + } + if let Some(issuer) = cert_info.issuer { + cert_info_obj.insert("issuer".to_string(), json!(issuer)); + } + if let Some(subject) = cert_info.subject { + cert_info_obj.insert("subject".to_string(), json!(subject)); + } + if let Some(validity) = cert_info.validity { + cert_info_obj.insert("validity".to_string(), validity); + } + } + claim_signature.insert("certificateInfo".to_string(), Value::Object(cert_info_obj)); + + // Build timeStampInfo when we have a signing time and/or TSA certificate + let has_ts_time = sig_info.time.is_some(); + let mut tsa_cert_info_obj = Map::new(); + if let Some(claim) = claim { + if let Ok(data) = claim.data() { + let sig_bytes = claim.signature_val(); + let mut log = StatusTracker::default(); + if let Ok(sign1) = parse_cose_sign1(sig_bytes, data.as_ref(), &mut log) { + if let Some(token_bytes) = timestamp_token_bytes_from_sign1(&sign1) { + if let Ok(Some(tsa_der)) = tsa_signer_cert_der_from_token(&token_bytes) { + if let Ok(Some(tsa_info)) = self.parse_certificate_from_der(&tsa_der) { + if let Some(serial) = tsa_info.serial_number { + tsa_cert_info_obj.insert("serialNumber".to_string(), json!(serial)); + } + if let Some(issuer) = tsa_info.issuer { + tsa_cert_info_obj.insert("issuer".to_string(), json!(issuer)); + } + if let Some(subject) = tsa_info.subject { + tsa_cert_info_obj.insert("subject".to_string(), json!(subject)); + } + if let Some(validity) = tsa_info.validity { + tsa_cert_info_obj.insert("validity".to_string(), validity); + } + } + } + } + } + } + } + if has_ts_time || !tsa_cert_info_obj.is_empty() { + let mut time_stamp_info = Map::new(); + if let Some(time) = &sig_info.time { + time_stamp_info.insert("timestamp".to_string(), json!(time)); + } + if !tsa_cert_info_obj.is_empty() { + time_stamp_info.insert("certificateInfo".to_string(), Value::Object(tsa_cert_info_obj)); + } + claim_signature.insert("timeStampInfo".to_string(), Value::Object(time_stamp_info)); + } + + Ok(Some(Value::Object(claim_signature))) + } + + /// Parse certificate to extract DN components and validity + fn parse_certificate(&self, cert_chain: &str) -> Result> { + // Parse PEM format certificate chain + let cert_der = self.parse_pem_to_der(cert_chain)?; + if cert_der.is_empty() { + return Ok(None); + } + + // Parse the first certificate (end entity) + let (_, cert) = X509Certificate::from_der(&cert_der[0]).map_err(|_e| { + Error::CoseInvalidCert // Use appropriate error type + })?; + + let mut details = CertificateDetails::default(); + + // Extract serial number in hex format + details.serial_number = Some(format!("{:x}", cert.serial)); + + // Extract issuer DN components + details.issuer = Some(self.extract_dn_components(cert.issuer())?); + + // Extract subject DN components + details.subject = Some(self.extract_dn_components(cert.subject())?); + + // Extract validity period + let not_before = cert.validity().not_before.to_datetime(); + let not_after = cert.validity().not_after.to_datetime(); + // Convert OffsetDateTime to RFC3339 format using chrono + let not_before_chrono: DateTime = + DateTime::from_timestamp(not_before.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + let not_after_chrono: DateTime = + DateTime::from_timestamp(not_after.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + details.validity = Some(json!({ + "notBefore": not_before_chrono.to_rfc3339(), + "notAfter": not_after_chrono.to_rfc3339() + })); + + Ok(Some(details)) + } + + /// Parse a single certificate (DER) to extract DN components and validity. + fn parse_certificate_from_der(&self, der: &[u8]) -> Result> { + let (_, cert) = X509Certificate::from_der(der).map_err(|_e| Error::CoseInvalidCert)?; + let mut details = CertificateDetails::default(); + details.serial_number = Some(format!("{:x}", cert.serial)); + details.issuer = Some(self.extract_dn_components(cert.issuer())?); + details.subject = Some(self.extract_dn_components(cert.subject())?); + let not_before = cert.validity().not_before.to_datetime(); + let not_after = cert.validity().not_after.to_datetime(); + let not_before_chrono: DateTime = + DateTime::from_timestamp(not_before.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + let not_after_chrono: DateTime = + DateTime::from_timestamp(not_after.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + details.validity = Some(json!({ + "notBefore": not_before_chrono.to_rfc3339(), + "notAfter": not_after_chrono.to_rfc3339() + })); + Ok(Some(details)) + } + + /// Extract DN components from X.509 name + fn extract_dn_components( + &self, + name: &x509_parser::x509::X509Name, + ) -> Result> { + let mut components = Map::new(); + + for rdn in name.iter() { + for attr in rdn.iter() { + let oid = attr.attr_type(); + let value = attr.as_str().map_err(|_| Error::CoseInvalidCert)?; + + // Map OIDs to standard abbreviations + let key = match oid.to_string().as_str() { + "2.5.4.6" => "C", // countryName + "2.5.4.8" => "ST", // stateOrProvinceName + "2.5.4.7" => "L", // localityName + "2.5.4.10" => "O", // organizationName + "2.5.4.11" => "OU", // organizationalUnitName + "2.5.4.3" => "CN", // commonName + _ => continue, // Skip unknown OIDs + }; + + components.insert(key.to_string(), json!(value)); + } + } + + Ok(components) + } + + /// Parse PEM format certificate chain to DER format + fn parse_pem_to_der(&self, pem_chain: &str) -> Result>> { + let mut certs = Vec::new(); + + for pem in pem_chain.split("-----BEGIN CERTIFICATE-----") { + if let Some(end_idx) = pem.find("-----END CERTIFICATE-----") { + // Extract PEM data and remove all whitespace (newlines, spaces, tabs) + let pem_data: String = pem[..end_idx] + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + if let Ok(der) = base64::decode(&pem_data) { + certs.push(der); + } + } + } + + Ok(certs) + } + + /// Decode cawg.identity signature field (COSE_Sign1) and extract certificate info + /// Similar to build_claim_signature but for the signature field in cawg.identity assertions + fn decode_cawg_signature(signature_bytes: &[u8]) -> Result { + use coset::{CoseSign1, TaggedCborSerializable}; + use crate::crypto::cose::{cert_chain_from_sign1, signing_alg_from_sign1}; + + // Parse COSE_Sign1 + let sign1 = ::from_tagged_slice(signature_bytes) + .map_err(|_| Error::CoseSignature)?; + + let mut signature_obj = Map::new(); + + // Extract algorithm from protected headers + if let Ok(alg) = signing_alg_from_sign1(&sign1) { + signature_obj.insert("algorithm".to_string(), json!(alg.to_string())); + } + + // Try to extract X.509 certificate chain (for cawg.x509.cose signatures) + if let Ok(cert_chain) = cert_chain_from_sign1(&sign1) { + if !cert_chain.is_empty() { + // Parse the first certificate (end entity) + if let Ok((_rem, cert)) = X509Certificate::from_der(&cert_chain[0]) { + let mut cert_info = Map::new(); + cert_info.insert("serialNumber".to_string(), json!(format!("{:x}", cert.serial))); + if let Ok(issuer) = Self::extract_dn_components_static(cert.issuer()) { + cert_info.insert("issuer".to_string(), json!(issuer)); + } + if let Ok(subject) = Self::extract_dn_components_static(cert.subject()) { + cert_info.insert("subject".to_string(), json!(subject)); + } + let not_before = cert.validity().not_before.to_datetime(); + let not_after = cert.validity().not_after.to_datetime(); + if let Some(not_before_chrono) = DateTime::::from_timestamp(not_before.unix_timestamp(), 0) { + if let Some(not_after_chrono) = DateTime::::from_timestamp(not_after.unix_timestamp(), 0) { + cert_info.insert("validity".to_string(), json!({ + "notBefore": not_before_chrono.to_rfc3339(), + "notAfter": not_after_chrono.to_rfc3339() + })); + } + } + signature_obj.insert("certificateInfo".to_string(), Value::Object(cert_info)); + } + } + } + + // If no certificate chain was found, try to extract Verifiable Credential + // (for cawg.identity_claims_aggregation signatures) + if !signature_obj.contains_key("certificateInfo") { + if let Some(payload) = sign1.payload.as_ref() { + // Try to parse payload as JSON (W3C Verifiable Credential) + if let Ok(vc_value) = serde_json::from_slice::(payload) { + // Extract issuer (DID) + if let Some(issuer) = vc_value.get("issuer") { + signature_obj.insert("issuer".to_string(), issuer.clone()); + } + + // Extract validity period + if let Some(valid_from) = vc_value.get("validFrom") { + signature_obj.insert("validFrom".to_string(), valid_from.clone()); + } + if let Some(valid_until) = vc_value.get("validUntil") { + signature_obj.insert("validUntil".to_string(), valid_until.clone()); + } + + // Extract verified identities from credential subject + if let Some(cred_subject) = vc_value.get("credentialSubject") { + if let Some(verified_ids) = cred_subject.get("verifiedIdentities") { + signature_obj.insert("verifiedIdentities".to_string(), verified_ids.clone()); + } + } + + // Mark this as an ICA credential + signature_obj.insert("credentialType".to_string(), json!("IdentityClaimsAggregation")); + } + } + } + + Ok(Value::Object(signature_obj)) + } + + /// Static version of extract_dn_components for use in decode_cawg_signature + fn extract_dn_components_static(name: &x509_parser::x509::X509Name) -> Result> { + let mut components = Map::new(); + + for rdn in name.iter() { + for attr in rdn.iter() { + let oid = attr.attr_type(); + let value = attr.as_str().map_err(|_| Error::CoseInvalidCert)?; + + // Map OIDs to standard abbreviations + let key = match oid.to_string().as_str() { + "2.5.4.6" => "C", // countryName + "2.5.4.8" => "ST", // stateOrProvinceName + "2.5.4.7" => "L", // localityName + "2.5.4.10" => "O", // organizationName + "2.5.4.11" => "OU", // organizationalUnitName + "2.5.4.3" => "CN", // commonName + _ => continue, // Skip unknown OIDs + }; + + components.insert(key.to_string(), json!(value)); + } + } + + Ok(components) + } + + /// Build document-level validationInfo object (signature codes array, trust, content, validationTime). + fn build_validation_info( + &self, + validation_results: &crate::validation_results::ValidationResults, + ) -> Result> { + let active_codes = match validation_results.active_manifest() { + Some(am) => am, + None => return Ok(None), + }; + + let mut info = Map::new(); + + let sig_codes = Self::find_all_validation_codes(active_codes, "claimSignature"); + if !sig_codes.is_empty() { + info.insert("signature".to_string(), json!(sig_codes)); + } + if let Some(trust_code) = Self::find_preferred_trust_code(active_codes) { + info.insert("trust".to_string(), json!(trust_code)); + } else if let Some(trust_code) = + Self::find_validation_code(&active_codes.success, "signingCredential") + { + info.insert("trust".to_string(), json!(trust_code)); + } else if let Some(trust_code) = + Self::find_validation_code(&active_codes.failure, "signingCredential") + { + info.insert("trust".to_string(), json!(trust_code)); + } + if let Some(content_code) = + Self::find_validation_code(&active_codes.success, "assertion.dataHash") + { + info.insert("content".to_string(), json!(content_code)); + } + + if let Some(t) = validation_results.validation_time() { + info.insert("validationTime".to_string(), json!(t)); + } + + Ok(Some(Value::Object(info))) + } + + /// Build validationResults (statusCodes) for a single manifest. Active manifest gets its codes; others get empty. + fn build_manifest_validation_results(&self, label: &str) -> Value { + let codes: StatusCodes = match ( + self.inner.active_label(), + self.inner.validation_results(), + ) { + (Some(active), Some(vr)) if active == label => { + vr.active_manifest().cloned().unwrap_or_default() + } + _ => StatusCodes::default(), + }; + serde_json::to_value(&codes).unwrap_or_else(|_| { + json!({ + "success": [], + "informational": [], + "failure": [] + }) + }) + } + + /// Build ingredientDeltas for a single manifest: deltas whose ingredientAssertionURI belongs to this manifest. + fn build_manifest_ingredient_deltas(&self, label: &str) -> Option { + let validation_results = self.inner.validation_results()?; + let deltas = validation_results.ingredient_deltas()?; + let for_manifest: Vec<_> = deltas + .iter() + .filter(|idv| { + manifest_label_from_uri(idv.ingredient_assertion_uri()).as_deref() == Some(label) + }) + .collect(); + if for_manifest.is_empty() { + return None; + } + serde_json::to_value(&for_manifest).ok() + } + + /// Find a preferred trust status (trusted, invalid, untrusted, expired) in the active manifest. + /// Returns the first match in that order; if none found, returns None (caller should fall back + /// to first signingCredential code). + fn find_preferred_trust_code(active_manifest: &crate::validation_results::StatusCodes) -> Option { + // Trusted is in success + if active_manifest.success.iter().any(|s| s.code() == SIGNING_CREDENTIAL_TRUSTED) { + return Some(SIGNING_CREDENTIAL_TRUSTED.to_string()); + } + // Invalid, untrusted, expired are in failure (check in that order) + for code in &[ + SIGNING_CREDENTIAL_INVALID, + SIGNING_CREDENTIAL_UNTRUSTED, + SIGNING_CREDENTIAL_EXPIRED, + ] { + if active_manifest.failure.iter().any(|s| s.code() == *code) { + return Some((*code).to_string()); + } + } + None + } + + /// Find a validation code matching a prefix in a list of validation statuses + fn find_validation_code(statuses: &[ValidationStatus], prefix: &str) -> Option { + statuses + .iter() + .find(|s| s.code().starts_with(prefix)) + .map(|s| s.code().to_string()) + } + + /// Collect all validation codes (from success, informational, failure) that start with the given prefix. + fn find_all_validation_codes( + active_codes: &crate::validation_results::StatusCodes, + prefix: &str, + ) -> Vec { + let mut codes: Vec = active_codes + .success() + .iter() + .chain(active_codes.informational().iter()) + .chain(active_codes.failure().iter()) + .filter(|s| s.code().starts_with(prefix)) + .map(|s| s.code().to_string()) + .collect(); + codes.dedup(); + codes + } + + /// Get a reference to the underlying Reader + pub fn inner(&self) -> &Reader { + &self.inner + } + + /// Get a mutable reference to the underlying Reader + pub fn inner_mut(&mut self) -> &mut Reader { + &mut self.inner + } +} + +/// Certificate details extracted from X.509 certificate +#[derive(Debug, Default)] +struct CertificateDetails { + serial_number: Option, + issuer: Option>, + subject: Option>, + validity: Option, +} + +/// Prints the JSON of the crJSON format manifest data. +impl std::fmt::Display for CrJsonReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.json().as_str()) + } +} + +/// Prints the full debug details of the crJSON format manifest data. +impl std::fmt::Debug for CrJsonReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let json = self.to_json_value().map_err(|_| std::fmt::Error)?; + let output = serde_json::to_string_pretty(&json).map_err(|_| std::fmt::Error)?; + f.write_str(&output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../tests/fixtures/CA.jpg"); + + #[test] + fn test_jpeg_trust_reader_from_stream() -> Result<()> { + let reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + assert_eq!(reader.validation_state(), ValidationState::Trusted); + Ok(()) + } + + #[test] + fn test_jpeg_trust_format_json() -> Result<()> { + let reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + let json_value = reader.to_json_value()?; + + // Verify required fields + assert!(json_value.get("@context").is_some()); + assert!(json_value.get("manifests").is_some()); + assert!(json_value.get("jsonGenerator").is_some(), "jsonGenerator must be present"); + + // jsonGenerator is c2pa-rs (this library) with name, version, date + let jg = &json_value["jsonGenerator"]; + assert_eq!(jg.get("name").and_then(|v| v.as_str()), Some("c2pa-rs")); + assert!(jg.get("version").and_then(|v| v.as_str()).is_some(), "jsonGenerator.version required"); + assert!(jg.get("date").is_some(), "jsonGenerator.date required"); + + // Verify manifests is an array + assert!(json_value["manifests"].is_array()); + + // Verify first manifest structure (required: label, assertions, signature, status; oneOf: claim or claim.v2) + if let Some(manifest) = json_value["manifests"].as_array().and_then(|a| a.first()) { + assert!(manifest.get("label").is_some()); + assert!(manifest.get("assertions").is_some()); + assert!(manifest.get("signature").is_some()); + assert!(manifest.get("validationResults").is_some()); + let has_claim = manifest.get("claim").is_some(); + let has_claim_v2 = manifest.get("claim.v2").is_some(); + assert!(has_claim != has_claim_v2, "manifest must have exactly one of claim (v1) or claim.v2"); + + // Verify assertions is an object (not array) + assert!(manifest["assertions"].is_object()); + + if let Some(claim_v2) = manifest.get("claim.v2") { + assert!(claim_v2.get("instanceID").is_some()); + assert!(claim_v2.get("signature").is_some()); + assert!(claim_v2.get("created_assertions").is_some()); + } else if let Some(claim_v1) = manifest.get("claim") { + assert!(claim_v1.get("claim_generator").is_some()); + assert!(claim_v1.get("claim_generator_info").is_some()); + assert!(claim_v1.get("signature").is_some()); + assert!(claim_v1.get("assertions").is_some()); + assert!(claim_v1.get("dc:format").is_some()); + assert!(claim_v1.get("instanceID").is_some()); + } + } + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_cr_json_reader_from_file() -> Result<()> { + let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + assert_eq!(reader.validation_state(), ValidationState::Trusted); + + let json = reader.json(); + assert!(json.contains("@context")); + assert!(json.contains("manifests")); + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_claim_signature_decoding() -> Result<()> { + // Test that signature (manifest-level) is decoded with full certificate details + let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + + let json_value = reader.to_json_value()?; + let manifests = json_value["manifests"].as_array().unwrap(); + // Every manifest has required "signature"; find one with decoded certificate details + let manifest = manifests + .iter() + .find(|m| m.get("signature").and_then(|s| s.get("algorithm")).is_some()); + assert!( + manifest.is_some(), + "Should have a manifest with signature containing algorithm" + ); + + let sig = &manifest.unwrap()["signature"]; + + // Verify algorithm is present + assert!(sig.get("algorithm").is_some(), "signature should have algorithm"); + + // Verify certificate details are decoded in certificateInfo (not just algorithm) + let cert_info = sig.get("certificateInfo").and_then(|c| c.as_object()); + assert!( + cert_info.is_some(), + "signature should have certificateInfo from decoded certificate" + ); + let cert_info = cert_info.unwrap(); + assert!( + cert_info.get("serialNumber").is_some(), + "certificateInfo should have serialNumber" + ); + assert!( + cert_info.get("issuer").is_some(), + "certificateInfo should have issuer" + ); + assert!( + cert_info.get("subject").is_some(), + "certificateInfo should have subject" + ); + assert!( + cert_info.get("validity").is_some(), + "certificateInfo should have validity" + ); + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_cawg_identity_x509_signature_decoding() -> Result<()> { + // Test that cawg.identity with X.509 signature is fully decoded + let reader = CrJsonReader::from_file("tests/fixtures/C_with_CAWG_data.jpg")?; + + let json_value = reader.to_json_value()?; + let manifests = json_value["manifests"].as_array().unwrap(); + + // Find the manifest and its cawg.identity assertion + let manifest = &manifests[0]; + let assertions = manifest["assertions"].as_object().unwrap(); + + let cawg_identity = assertions.get("cawg.identity"); + assert!(cawg_identity.is_some(), "Should have cawg.identity assertion"); + + let cawg_identity = cawg_identity.unwrap(); + + // Verify pad1 and pad2 are base64 strings, not arrays + assert!( + cawg_identity["pad1"].is_string(), + "pad1 should be a base64 string, not an array" + ); + if cawg_identity.get("pad2").is_some() { + assert!( + cawg_identity["pad2"].is_string(), + "pad2 should be a base64 string, not an array" + ); + } + + // Verify signature is decoded + let signature = &cawg_identity["signature"]; + assert!(signature.is_object(), "signature should be an object, not an array"); + + // For X.509 signatures (sig_type: cawg.x509.cose), verify certificate details in certificateInfo + let sig_type = cawg_identity["signer_payload"]["sig_type"].as_str().unwrap(); + if sig_type == "cawg.x509.cose" { + assert!( + signature.get("algorithm").is_some(), + "signature should have algorithm" + ); + let cert_info = signature.get("certificateInfo").and_then(|c| c.as_object()); + assert!( + cert_info.is_some(), + "X.509 signature should have certificateInfo" + ); + let cert_info = cert_info.unwrap(); + assert!( + cert_info.get("serialNumber").is_some(), + "certificateInfo should have serialNumber" + ); + assert!( + cert_info.get("issuer").is_some(), + "certificateInfo should have issuer DN components" + ); + assert!( + cert_info.get("subject").is_some(), + "certificateInfo should have subject DN components" + ); + assert!( + cert_info.get("validity").is_some(), + "certificateInfo should have validity period" + ); + } + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_cawg_identity_ica_signature_decoding() -> Result<()> { + // Test that cawg.identity with ICA signature extracts VC information + // Note: This test uses a fixture from the identity tests + let test_image = include_bytes!("identity/tests/fixtures/claim_aggregation/adobe_connected_identities.jpg"); + + let reader = CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(&test_image[..]))?; + + let json_value = reader.to_json_value()?; + let manifests = json_value["manifests"].as_array().unwrap(); + + // Find a manifest with cawg.identity assertion + let manifest = manifests.iter().find(|m| { + if let Some(assertions) = m.get("assertions").and_then(|a| a.as_object()) { + assertions.keys().any(|k| k.starts_with("cawg.identity")) + } else { + false + } + }); + + if let Some(manifest) = manifest { + let assertions = manifest["assertions"].as_object().unwrap(); + let cawg_identity_key = assertions.keys().find(|k| k.starts_with("cawg.identity")).unwrap(); + let cawg_identity = &assertions[cawg_identity_key]; + + // Verify pad fields are base64 strings + assert!( + cawg_identity["pad1"].is_string(), + "pad1 should be a base64 string" + ); + + // Verify signature is decoded + let signature = &cawg_identity["signature"]; + assert!(signature.is_object(), "signature should be an object"); + + // For ICA signatures (sig_type: cawg.identity_claims_aggregation), + // verify VC information is extracted + let sig_type = cawg_identity["signer_payload"]["sig_type"].as_str().unwrap(); + if sig_type == "cawg.identity_claims_aggregation" { + assert!( + signature.get("algorithm").is_some(), + "ICA signature should have algorithm" + ); + assert!( + signature.get("issuer").is_some(), + "ICA signature should have issuer (DID)" + ); + + // ICA signatures should have VC-specific fields + // Note: Some of these may be optional depending on the VC + let has_vc_info = signature.get("validFrom").is_some() + || signature.get("validUntil").is_some() + || signature.get("verifiedIdentities").is_some() + || signature.get("credentialType").is_some(); + + assert!( + has_vc_info, + "ICA signature should have at least some VC information (validFrom, validUntil, verifiedIdentities, or credentialType)" + ); + } + } + + Ok(()) + } + + /// Test that claim_generator_info (including icon) is exported to claim.v2 when present. + /// Uses an image produced by crTool with a claim generator icon. + #[test] + #[cfg(feature = "file_io")] + fn test_claim_generator_info_with_icon_exported() -> Result<()> { + use std::path::Path; + + let path = Path::new("/Users/lrosenth/Development/crTool/target/test_output/testset/p-actions-created-with-icon.jpg"); + if !path.exists() { + eprintln!("Skipping test_claim_generator_info_with_icon_exported: fixture not found at {:?}", path); + return Ok(()); + } + + let reader = CrJsonReader::from_file(path)?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be an array"); + let manifest = manifests + .first() + .expect("should have at least one manifest"); + + let claim_block = manifest + .get("claim.v2") + .or_else(|| manifest.get("claim")) + .expect("manifest should have claim or claim.v2"); + + let claim_generator_info = claim_block + .get("claim_generator_info") + .expect("claim should include claim_generator_info when manifest has an icon"); + + // claim.v2: single object; claim (v1): array โ€” get first object for assertion + let info_obj = claim_generator_info + .as_object() + .or_else(|| claim_generator_info.as_array().and_then(|a| a.first()).and_then(|v| v.as_object())) + .expect("claim_generator_info should be an object or array of objects"); + + // Icon must be hashedUriMap only (url, hash, optional alg) per schema; no format/identifier. + let has_icon = match info_obj.get("icon") { + Some(icon) => icon + .get("url") + .and_then(|v| v.as_str()) + .is_some() + && icon.get("hash").and_then(|v| v.as_str()).is_some(), + None => false, + }; + + assert!( + has_icon, + "claim_generator_info should include icon as hashedUriMap (url, hash)" + ); + + Ok(()) + } +} diff --git a/sdk/src/crypto/cose/mod.rs b/sdk/src/crypto/cose/mod.rs index 32ca63675..d82cf258f 100644 --- a/sdk/src/crypto/cose/mod.rs +++ b/sdk/src/crypto/cose/mod.rs @@ -47,7 +47,8 @@ pub use sign1::{ mod sigtst; pub(crate) use sigtst::{ add_sigtst_header, add_sigtst_header_async, get_cose_tst_info, - timestamptoken_from_timestamprsp, validate_cose_tst_info, validate_cose_tst_info_async, + timestamptoken_from_timestamprsp, timestamp_token_bytes_from_sign1, + validate_cose_tst_info, validate_cose_tst_info_async, }; mod time_stamp_storage; diff --git a/sdk/src/crypto/cose/sigtst.rs b/sdk/src/crypto/cose/sigtst.rs index 8bcf3d5ea..50672e26f 100644 --- a/sdk/src/crypto/cose/sigtst.rs +++ b/sdk/src/crypto/cose/sigtst.rs @@ -54,6 +54,16 @@ pub(crate) fn get_cose_tst_info(sign1: &coset::CoseSign1) -> Option<(&Value, Tim }) } +/// Extract the raw RFC 3161 timestamp token bytes from a COSE Sign1 (sigTst/sigTst2 header). +/// Returns the first token's bytes if present, for use when parsing TSA certificate info. +pub(crate) fn timestamp_token_bytes_from_sign1(sign1: &coset::CoseSign1) -> Option> { + let (sigtst, _) = get_cose_tst_info(sign1)?; + let mut time_cbor = Vec::new(); + coset::cbor::into_writer(sigtst, &mut time_cbor).ok()?; + let tst_container: TstContainer = coset::cbor::from_reader(time_cbor.as_slice()).ok()?; + tst_container.tst_tokens.first().map(|t| t.val.clone()) +} + /// Given a COSE signature, retrieve the `sigTst` header from it and validate /// the information within it. /// diff --git a/sdk/src/crypto/time_stamp/mod.rs b/sdk/src/crypto/time_stamp/mod.rs index d71216bad..a13b9f25f 100644 --- a/sdk/src/crypto/time_stamp/mod.rs +++ b/sdk/src/crypto/time_stamp/mod.rs @@ -28,4 +28,6 @@ mod response; pub(crate) use response::{ContentInfo, TimeStampResponse}; mod verify; -pub use verify::{verify_time_stamp, verify_time_stamp_async}; +pub use verify::{ + tsa_signer_cert_der_from_token, verify_time_stamp, verify_time_stamp_async, +}; diff --git a/sdk/src/crypto/time_stamp/verify.rs b/sdk/src/crypto/time_stamp/verify.rs index 68a677fab..cfe0a3371 100644 --- a/sdk/src/crypto/time_stamp/verify.rs +++ b/sdk/src/crypto/time_stamp/verify.rs @@ -253,7 +253,7 @@ pub fn verify_time_stamp( .informational(&mut current_validation_log); last_err = TimeStampError::DecodeError( - "unable to decode igned message data".to_string(), + "unable to decode signed message data".to_string(), ); continue; } @@ -576,6 +576,65 @@ pub fn verify_time_stamp( Err(last_err) } +/// Extract the TSA signer certificate (DER) from an RFC 3161 timestamp token. +/// Does not verify the token; only parses it and returns the first signer's certificate. +pub fn tsa_signer_cert_der_from_token(ts: &[u8]) -> Result>, TimeStampError> { + let Some(sd) = signed_data_from_time_stamp_response(ts)? else { + return Ok(None); + }; + let Some(certs) = &sd.certificates else { + return Ok(None); + }; + let certs_vec = certs.to_vec(); + let cert_ders: Vec> = certs_vec + .iter() + .filter_map(|cc| { + if let CertificateChoices::Certificate(c) = cc { + rasn::der::encode(c).ok() + } else { + None + } + }) + .collect(); + if cert_ders.len() != certs_vec.len() { + return Err(TimeStampError::DecodeError( + "time stamp certificate could not be processed".to_string(), + )); + } + let Some(signer_info) = sd.signer_infos.to_vec().into_iter().next() else { + return Ok(None); + }; + let Some(cert_pos) = certs_vec.iter().position(|cc| { + let c = match cc { + CertificateChoices::Certificate(c) => c, + _ => return false, + }; + match &signer_info.sid { + SignerIdentifier::IssuerAndSerialNumber(sn) => { + sn.issuer == c.tbs_certificate.issuer + && sn.serial_number == c.tbs_certificate.serial_number + } + SignerIdentifier::SubjectKeyIdentifier(ski) => { + if let Some(extensions) = &c.tbs_certificate.extensions { + extensions.iter().any(|e| { + if e.extn_id + == Oid::JOINT_ISO_ITU_T_DS_CERTIFICATE_EXTENSION_SUBJECT_KEY_IDENTIFIER + { + return *ski == e.extn_value; + } + false + }) + } else { + false + } + } + } + }) else { + return Ok(None); + }; + Ok(Some(cert_ders[cert_pos].clone())) +} + fn generalized_time_to_datetime>>(gt: T) -> DateTime { gt.into() } diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index f97ee042d..8fcae8130 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -221,6 +221,9 @@ pub mod identity; /// The `jumbf_io` module contains the definitions for the JUMBF data in assets. pub mod jumbf_io; +/// The cr_json_reader module provides a Reader-like API that exports C2PA manifests in crJSON format. +pub mod cr_json_reader; + /// The settings module provides a way to configure the C2PA SDK. pub mod settings; @@ -260,6 +263,7 @@ pub use ingredient::{DefaultOptions, IngredientOptions}; pub use manifest::{Manifest, SignatureInfo}; pub use manifest_assertion::{ManifestAssertion, ManifestAssertionKind}; pub use reader::Reader; +pub use cr_json_reader::CrJsonReader; #[doc(inline)] pub use resource_store::{ResourceRef, ResourceStore}; #[doc(inline)] diff --git a/sdk/src/resource_store.rs b/sdk/src/resource_store.rs index c3628145b..deaf16bb3 100644 --- a/sdk/src/resource_store.rs +++ b/sdk/src/resource_store.rs @@ -33,6 +33,7 @@ use crate::{ assertions::{labels, AssetType, EmbeddedData}, asset_io::CAIRead, claim::Claim, + crypto::base64, error::Error, hashed_uri::HashedUri, jumbf::labels::{assertion_label_from_uri, to_absolute_uri, DATABOXES}, @@ -99,7 +100,10 @@ impl UriOrResource { ) }; let url = to_absolute_uri(claim.label(), &h.url()); - let resource_ref = resources.add_with(&url, &format, data)?; + let mut resource_ref = resources.add_uri(&url, &format, data)?; + // Preserve hash and alg so crJSON (and other consumers) can always emit hashedUriMap + resource_ref.hash = Some(base64::encode(&h.hash())); + resource_ref.alg = h.alg(); Ok(UriOrResource::ResourceRef(resource_ref)) } } diff --git a/sdk/src/validation_results.rs b/sdk/src/validation_results.rs index a07fc48ed..4d89d1b8a 100644 --- a/sdk/src/validation_results.rs +++ b/sdk/src/validation_results.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; +use chrono::Utc; #[cfg(feature = "json_schema")] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -26,7 +27,7 @@ use crate::{ validation_status::{self, log_kind, ValidationStatus}, }; -/// Represents the levels of assurance a manifest store achives when evaluated against the C2PA +/// Represents the levels of assurance a manifest store achieves when evaluated against the C2PA /// specifications structural, cryptographic, and trust requirements. /// /// See [Validation states - C2PA Technical Specification](https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_validation_states). @@ -57,7 +58,7 @@ pub struct StatusCodes { pub success: Vec, /// An array of validation informational codes. May be empty. pub informational: Vec, - // An array of validation failure codes. May be empty. + /// An array of validation failure codes. May be empty. pub failure: Vec, } @@ -115,6 +116,10 @@ pub struct ValidationResults { /// manifest. Present if the the ingredient is a C2PA asset. #[serde(rename = "ingredientDeltas", skip_serializing_if = "Option::is_none")] ingredient_deltas: Option>, + + /// Time when the validation was performed (RFC 3339 date-time). Used only for document-level validationInfo; not serialized in validationResults (e.g. ingredient assertions). + #[serde(rename = "validationTime", skip_serializing)] + validation_time: Option, } impl ValidationResults { @@ -128,7 +133,12 @@ impl ValidationResults { .collect(); // Filter out any status that is already captured in an ingredient assertion. + // There is always an active manifest in a manifest store; ensure active_manifest is set + // so serialization (e.g. crJSON) always includes activeManifest when validationResults exist. if let Some(claim) = store.provenance_claim() { + let _ = results + .active_manifest + .get_or_insert_with(StatusCodes::default); let active_manifest = Some(claim.label().to_string()); // This closure returns true if the URI references the store's active manifest. @@ -194,6 +204,7 @@ impl ValidationResults { results.add_status(status); } } + results.validation_time = Some(Utc::now().to_rfc3339()); results } @@ -316,6 +327,11 @@ impl ValidationResults { self.ingredient_deltas.as_ref() } + /// Returns the time when validation was performed (RFC 3339), if set. + pub fn validation_time(&self) -> Option<&str> { + self.validation_time.as_deref() + } + pub fn add_active_manifest(mut self, scm: StatusCodes) -> Self { self.active_manifest = Some(scm); self diff --git a/sdk/tests/crjson/asset_hash.rs b/sdk/tests/crjson/asset_hash.rs new file mode 100644 index 000000000..f4818d6eb --- /dev/null +++ b/sdk/tests/crjson/asset_hash.rs @@ -0,0 +1,63 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +//! Integration tests for CrJsonReader output structure. +//! CrJSON format does not include asset_info, content, or metadata. + +use c2pa::{CrJsonReader, Result}; +use std::io::Cursor; + +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); + +#[test] +fn test_cr_json_omits_asset_info_content_metadata() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + let json_value = reader.to_json_value()?; + + // CrJSON does not include these top-level properties + assert!( + json_value.get("asset_info").is_none(), + "asset_info should not be present in CrJSON output" + ); + assert!( + json_value.get("content").is_none(), + "content should not be present in CrJSON output" + ); + assert!( + json_value.get("metadata").is_none(), + "metadata should not be present in CrJSON output" + ); + + // Required CrJSON fields should still be present + assert!(json_value.get("@context").is_some()); + assert!(json_value.get("manifests").is_some()); + + Ok(()) +} + +#[test] +#[cfg(feature = "file_io")] +fn test_cr_json_from_file_omits_asset_info_content_metadata() -> Result<()> { + let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + + let json_value = reader.to_json_value()?; + + assert!(json_value.get("asset_info").is_none()); + assert!(json_value.get("content").is_none()); + assert!(json_value.get("metadata").is_none()); + assert!(json_value.get("@context").is_some()); + assert!(json_value.get("manifests").is_some()); + + Ok(()) +} diff --git a/sdk/tests/crjson/created_gathered.rs b/sdk/tests/crjson/created_gathered.rs new file mode 100644 index 000000000..0c11411a1 --- /dev/null +++ b/sdk/tests/crjson/created_gathered.rs @@ -0,0 +1,260 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +//! Tests for crJSON format created_assertions vs gathered_assertions + +use std::io::Cursor; + +use c2pa::{Builder, Context, CrJsonReader, Result, Settings}; + +const TEST_IMAGE: &[u8] = include_bytes!("../fixtures/CA.jpg"); +const TEST_SETTINGS: &str = include_str!("../fixtures/test_settings.toml"); + +#[test] +fn test_created_and_gathered_assertions_separated() -> Result<()> { + use serde_json::json; + + let settings = Settings::new().with_toml(TEST_SETTINGS)?; + let context = Context::new().with_settings(settings)?.into_shared(); + + let format = "image/jpeg"; + let mut source = Cursor::new(TEST_IMAGE); + + // Create a manifest with both created and gathered assertions + let definition = json!( + { + "assertions": [ + { + "label": "org.test.gathered", + "data": { + "value": "gathered assertion" + } + }, + { + "label": "org.test.created", + "kind": "Json", + "data": { + "value": "created assertion" + }, + "created": true + }] + } + ) + .to_string(); + + let mut builder = Builder::from_shared_context(&context).with_definition(&definition)?; + + // Add another regular assertion (should default to gathered) + builder.add_assertion("org.test.regular", &json!({"value": "regular assertion"}))?; + + let mut dest = Cursor::new(Vec::new()); + builder.sign(context.signer()?, format, &mut source, &mut dest)?; + + // Now read it with CrJsonReader + dest.set_position(0); + let reader = CrJsonReader::from_stream(format, dest)?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + // Find the manifest that contains our test assertions (org.test.created, org.test.gathered, org.test.regular) + let active_manifest = manifests + .iter() + .find(|m| { + m.get("assertions") + .and_then(|a| a.as_object()) + .map(|obj| { + obj.contains_key("org.test.created") + && obj.contains_key("org.test.gathered") + && obj.contains_key("org.test.regular") + }) + .unwrap_or(false) + }) + .expect("should have a manifest with our test assertions (org.test.created, org.test.gathered, org.test.regular)"); + + // Check claim.v2 for created_assertions and gathered_assertions + let claim_v2 = active_manifest["claim.v2"] + .as_object() + .expect("claim.v2 should exist"); + + let created_assertions = claim_v2["created_assertions"] + .as_array() + .expect("created_assertions should be array"); + + let gathered_assertions = claim_v2["gathered_assertions"] + .as_array() + .expect("gathered_assertions should be array"); + + // Verify that gathered_assertions is not empty + assert!( + !gathered_assertions.is_empty(), + "gathered_assertions should not be empty, but got {} gathered vs {} created", + gathered_assertions.len(), + created_assertions.len() + ); + + // Find the created assertion - should be in created_assertions + let has_created_ref = created_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("org.test.created")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_created_ref, + "created_assertions should reference org.test.created" + ); + + // Find the gathered assertion - should be in gathered_assertions + let has_gathered_ref = gathered_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("org.test.gathered")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_gathered_ref, + "gathered_assertions should reference org.test.gathered" + ); + + // Verify the regular assertion is in gathered_assertions (default behavior) + let has_regular_ref = gathered_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("org.test.regular")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_regular_ref, + "gathered_assertions should reference org.test.regular (default)" + ); + + // Verify all assertion references have proper hash format + for assertion_ref in created_assertions.iter().chain(gathered_assertions.iter()) { + assert!( + assertion_ref.get("url").is_some(), + "All assertion refs should have url" + ); + assert!( + assertion_ref.get("hash").is_some(), + "All assertion refs should have hash" + ); + + let hash = assertion_ref.get("hash").unwrap(); + assert!( + hash.is_string(), + "Hash should be a string (base64), not an array" + ); + } + + Ok(()) +} + +#[test] +fn test_hash_assertions_in_created() -> Result<()> { + use serde_json::json; + + let settings = Settings::new().with_toml(TEST_SETTINGS)?; + let context = Context::new().with_settings(settings)?.into_shared(); + + let format = "image/jpeg"; + let mut source = Cursor::new(TEST_IMAGE); + + // Create a simple manifest + let definition = json!( + { + "assertions": [ + { + "label": "org.test.simple", + "data": { + "value": "test" + } + }] + } + ) + .to_string(); + + let mut builder = Builder::from_shared_context(&context).with_definition(&definition)?; + + let mut dest = Cursor::new(Vec::new()); + builder.sign(context.signer()?, format, &mut source, &mut dest)?; + + // Now read it with CrJsonReader + dest.set_position(0); + let reader = CrJsonReader::from_stream(format, dest)?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + // Find the manifest with our test assertions (the newly created one with claim v2) + let active_manifest = manifests + .iter() + .filter(|m| { + // Filter for claim v2 manifests with non-empty created_assertions + if let Some(claim_v2) = m.get("claim.v2") { + if let Some(created) = claim_v2.get("created_assertions") { + if let Some(arr) = created.as_array() { + return !arr.is_empty(); + } + } + } + false + }) + .last() + .expect("should have at least one claim v2 manifest"); + + // Check claim.v2 + let claim_v2 = active_manifest["claim.v2"] + .as_object() + .expect("claim.v2 should exist"); + + let created_assertions = claim_v2["created_assertions"] + .as_array() + .expect("created_assertions should be array"); + + // Hash assertions (c2pa.hash.data, etc.) should be in created_assertions + let has_hash_assertion = created_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("c2pa.hash")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_hash_assertion, + "created_assertions should include hash assertions (c2pa.hash.*)" + ); + + Ok(()) +} diff --git a/sdk/tests/crjson/hash_assertions.rs b/sdk/tests/crjson/hash_assertions.rs new file mode 100644 index 000000000..f1ff63b9b --- /dev/null +++ b/sdk/tests/crjson/hash_assertions.rs @@ -0,0 +1,287 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +use std::io::Cursor; + +use c2pa::{CrJsonReader, Result}; + +// Test image with manifest +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/C.jpg"); + +#[test] +fn test_hash_data_assertion_included() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check that c2pa.hash.data is present + assert!( + assertions.contains_key("c2pa.hash.data"), + "Should have c2pa.hash.data assertion" + ); + + Ok(()) +} + +#[test] +fn test_hash_data_structure() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + let hash_data = assertions + .get("c2pa.hash.data") + .expect("Should have c2pa.hash.data"); + + let hash_data_obj = hash_data.as_object().expect("hash.data should be object"); + + // Check required fields + assert!(hash_data_obj.contains_key("hash"), "Should have hash field"); + assert!(hash_data_obj.contains_key("alg"), "Should have alg field"); + + // Verify hash is base64 string, not byte array + let hash = hash_data_obj["hash"] + .as_str() + .expect("hash should be a string (base64 encoded)"); + assert!(!hash.is_empty(), "hash should not be empty"); + + // Verify the hash value doesn't look like a byte array representation + // (if it were an array, it would serialize as an array in JSON) + assert!( + !hash_data_obj["hash"].is_array(), + "hash should be a string, not an array" + ); + + Ok(()) +} + +#[test] +fn test_hash_data_algorithm() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + let hash_data = assertions + .get("c2pa.hash.data") + .expect("Should have c2pa.hash.data"); + + let alg = hash_data["alg"] + .as_str() + .expect("alg should be a string"); + + // Algorithm should be one of the standard hash algorithms + assert!( + alg == "sha256" || alg == "sha384" || alg == "sha512", + "Algorithm should be a standard hash algorithm, got: {}", + alg + ); + + Ok(()) +} + +#[test] +fn test_multiple_hash_assertions() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Count hash assertions (c2pa.hash.data, c2pa.hash.bmff, c2pa.hash.boxes) + let hash_assertion_count = assertions + .keys() + .filter(|k| k.starts_with("c2pa.hash.")) + .count(); + + // Should have at least one hash assertion + assert!( + hash_assertion_count >= 1, + "Should have at least one hash assertion, found {}", + hash_assertion_count + ); + + Ok(()) +} + +#[test] +fn test_hash_data_not_filtered() -> Result<()> { + // This test ensures that c2pa.hash.data is included in crJSON format + // even though it's filtered out in the standard Manifest format + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Verify that hash.data is included + assert!( + assertions.contains_key("c2pa.hash.data"), + "c2pa.hash.data should be included in crJSON format" + ); + + // Compare with standard Reader to confirm it's normally filtered + let standard_reader = c2pa::Reader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let standard_json = serde_json::to_value(standard_reader)?; + + // In standard format, manifests is a map, not an array + let manifests_map = standard_json["manifests"] + .as_object() + .expect("standard manifests should be object"); + + let first_standard_manifest = manifests_map.values().next().expect("should have a manifest"); + let standard_assertions = first_standard_manifest["assertions"] + .as_array() + .expect("standard assertions should be array"); + + // Verify it's NOT in the standard assertions array + let has_hash_data_in_standard = standard_assertions + .iter() + .any(|a| a.get("label").and_then(|l| l.as_str()) == Some("c2pa.hash.data")); + + assert!( + !has_hash_data_in_standard, + "c2pa.hash.data should be filtered out in standard format" + ); + + Ok(()) +} + +#[test] +fn test_hash_assertion_versioning() -> Result<()> { + // This test verifies that hash assertions with versions (e.g., c2pa.hash.bmff.v2, v3) + // are correctly labeled with their version suffix + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check all hash assertion keys + for (key, _value) in assertions.iter() { + if key.starts_with("c2pa.hash.") { + // If this is a versioned assertion, it should have .v{N} suffix + // The key should be one of: + // - c2pa.hash.data (v1, no suffix) + // - c2pa.hash.data.v2 + // - c2pa.hash.data.v3 + // - c2pa.hash.bmff (v1, no suffix) + // - c2pa.hash.bmff.v2 + // - c2pa.hash.bmff.v3 + // - c2pa.hash.boxes (v1, no suffix) + // - etc. + + // Remove any instance suffix (_1, _2, etc.) for checking + let base_key = key.split('_').next().unwrap_or(key); + + // Verify the label follows correct versioning pattern + let is_valid = base_key == "c2pa.hash.data" + || base_key == "c2pa.hash.bmff" + || base_key == "c2pa.hash.boxes" + || base_key == "c2pa.hash.collection.data" + || base_key.starts_with("c2pa.hash.data.v") + || base_key.starts_with("c2pa.hash.bmff.v") + || base_key.starts_with("c2pa.hash.boxes.v") + || base_key.starts_with("c2pa.hash.collection.data.v"); + + assert!( + is_valid, + "Hash assertion key '{}' should follow versioning pattern", + key + ); + } + } + + Ok(()) +} + +#[test] +fn test_hash_assertion_pad_encoding() -> Result<()> { + // This test verifies that the 'pad' field in hash assertions is base64 encoded, + // not an array of integers + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + let hash_data = assertions + .get("c2pa.hash.data") + .expect("Should have c2pa.hash.data"); + + let hash_data_obj = hash_data.as_object().expect("hash.data should be object"); + + // Check if pad field exists + if let Some(pad_value) = hash_data_obj.get("pad") { + // Verify pad is a base64 string, not a byte array + assert!( + pad_value.is_string(), + "pad should be a string (base64), not an array" + ); + + let _pad = pad_value.as_str().expect("pad should be a string"); + + // Verify it's not empty (unless the pad is actually empty) + // An empty pad would encode to an empty string + + // Verify the pad value doesn't look like an array representation + assert!( + !pad_value.is_array(), + "pad should be a string, not an array" + ); + } + + Ok(()) +} + + diff --git a/sdk/tests/crjson/hash_encoding.rs b/sdk/tests/crjson/hash_encoding.rs new file mode 100644 index 000000000..05a2b3384 --- /dev/null +++ b/sdk/tests/crjson/hash_encoding.rs @@ -0,0 +1,314 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +//! Tests for hash encoding in crJSON format +//! +//! Verifies that all hash fields are properly encoded as base64 strings +//! rather than byte arrays. + +use c2pa::{CrJsonReader, Result}; +use serde_json::Value; +use std::io::Cursor; + +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); + +/// Recursively check that all "hash" fields in the JSON are strings (base64), +/// not arrays of integers. +fn verify_no_byte_array_hashes(value: &Value, path: &str) -> Vec { + let mut errors = Vec::new(); + + match value { + Value::Object(map) => { + // Check if this object has a "hash" field + if let Some(hash_value) = map.get("hash") { + let current_path = format!("{}.hash", path); + + if hash_value.is_array() { + // This is bad - hash should not be an array + errors.push(format!( + "Found byte array hash at {}: {:?}", + current_path, hash_value + )); + } else if let Some(hash_str) = hash_value.as_str() { + // Good - it's a string. Verify it looks like base64 + if !is_valid_base64(hash_str) { + errors.push(format!( + "Hash at {} is not valid base64: {}", + current_path, hash_str + )); + } + } + } + + // Recursively check all values + for (key, val) in map { + let new_path = if path.is_empty() { + key.clone() + } else { + format!("{}.{}", path, key) + }; + errors.extend(verify_no_byte_array_hashes(val, &new_path)); + } + } + Value::Array(arr) => { + // Recursively check all array elements + for (i, val) in arr.iter().enumerate() { + let new_path = format!("{}[{}]", path, i); + errors.extend(verify_no_byte_array_hashes(val, &new_path)); + } + } + _ => {} + } + + errors +} + +/// Check if a string is valid base64 +fn is_valid_base64(s: &str) -> bool { + // Base64 characters are A-Z, a-z, 0-9, +, /, and = for padding + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + && !s.is_empty() +} + +#[test] +fn test_no_byte_array_hashes() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Verify no byte array hashes exist anywhere in the output + let errors = verify_no_byte_array_hashes(&json_value, ""); + + if !errors.is_empty() { + panic!( + "Found {} byte array hash(es) in output:\n{}", + errors.len(), + errors.join("\n") + ); + } + + Ok(()) +} + +#[test] +fn test_action_ingredient_hash_is_base64() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Navigate to the actions assertion + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests + .first() + .expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check c2pa.actions.v2 if it exists + if let Some(actions_assertion) = assertions.get("c2pa.actions.v2") { + let actions = actions_assertion["actions"] + .as_array() + .expect("actions should be array"); + + for (i, action) in actions.iter().enumerate() { + // Check if this action has ingredient parameter + if let Some(params) = action.get("parameters") { + if let Some(ingredient) = params.get("ingredient") { + if let Some(hash) = ingredient.get("hash") { + assert!( + hash.is_string(), + "Action {} ingredient hash should be string, not array", + i + ); + + let hash_str = hash.as_str().unwrap(); + assert!( + is_valid_base64(hash_str), + "Action {} ingredient hash should be valid base64: {}", + i, + hash_str + ); + + // Verify it's not empty + assert!( + !hash_str.is_empty(), + "Action {} ingredient hash should not be empty", + i + ); + } + } + } + } + } + + Ok(()) +} + +#[test] +fn test_assertion_reference_hashes_are_base64() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Check created_assertions hashes in claim.v2 (manifest may have claim v1 or claim.v2 per schema) + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests + .first() + .expect("should have at least one manifest"); + let claim_v2 = first_manifest + .get("claim.v2") + .and_then(|v| v.as_object()); + + if let Some(claim_v2) = claim_v2 { + if let Some(created_assertions) = claim_v2.get("created_assertions") { + let assertions_array = created_assertions + .as_array() + .expect("created_assertions should be array"); + + for (i, assertion_ref) in assertions_array.iter().enumerate() { + if let Some(hash) = assertion_ref.get("hash") { + assert!( + hash.is_string(), + "Assertion reference {} hash should be string, not array", + i + ); + + let hash_str = hash.as_str().unwrap(); + assert!( + is_valid_base64(hash_str), + "Assertion reference {} hash should be valid base64: {}", + i, + hash_str + ); + } + } + } + } + // If manifest has claim (v1) only, created_assertions is not present; nothing to check. + + Ok(()) +} + +#[test] +fn test_ingredient_assertion_hashes_are_base64() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Check ingredient assertions + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests + .first() + .expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check for ingredient assertions (can have various labels) + for (label, assertion_value) in assertions { + if label.contains("ingredient") { + // Check c2pa_manifest hash + if let Some(c2pa_manifest) = assertion_value.get("c2pa_manifest") { + if let Some(hash) = c2pa_manifest.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} c2pa_manifest hash should be string", + label + ); + } + } + + // Check thumbnail hash + if let Some(thumbnail) = assertion_value.get("thumbnail") { + if let Some(hash) = thumbnail.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} thumbnail hash should be string", + label + ); + } + } + + // Check activeManifest hash + if let Some(active_manifest) = assertion_value.get("activeManifest") { + if let Some(hash) = active_manifest.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} activeManifest hash should be string", + label + ); + } + } + + // Check claimSignature hash + if let Some(claim_signature) = assertion_value.get("claimSignature") { + if let Some(hash) = claim_signature.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} claimSignature hash should be string", + label + ); + } + } + } + } + + Ok(()) +} + +#[test] +fn test_all_hashes_match_schema_format() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Collect all hash values + let mut hash_count = 0; + + fn count_hashes(value: &Value, counter: &mut usize) { + match value { + Value::Object(map) => { + if let Some(hash_value) = map.get("hash") { + if hash_value.is_string() { + *counter += 1; + } + } + for val in map.values() { + count_hashes(val, counter); + } + } + Value::Array(arr) => { + for val in arr { + count_hashes(val, counter); + } + } + _ => {} + } + } + + count_hashes(&json_value, &mut hash_count); + + // Should have multiple hashes in a typical manifest + assert!( + hash_count > 0, + "Should have at least one hash field in the output" + ); + + Ok(()) +} diff --git a/sdk/tests/crjson/ingredients.rs b/sdk/tests/crjson/ingredients.rs new file mode 100644 index 000000000..aab42ed33 --- /dev/null +++ b/sdk/tests/crjson/ingredients.rs @@ -0,0 +1,294 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +//! Tests for ingredient assertions in crJSON format + +use c2pa::{CrJsonReader, Result}; +use std::io::Cursor; + +const IMAGE_WITH_INGREDIENT: &[u8] = include_bytes!("../fixtures/CA.jpg"); + +#[test] +fn test_ingredient_assertions_included() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + // Check first manifest for ingredient assertion + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Should have ingredient assertion + assert!( + assertions.contains_key("c2pa.ingredient"), + "assertions should contain c2pa.ingredient" + ); + + // Verify ingredient structure + let ingredient = &assertions["c2pa.ingredient"]; + assert!(ingredient.is_object(), "ingredient should be an object"); + + // Check for expected ingredient fields + let ingredient_obj = ingredient.as_object().unwrap(); + assert!( + ingredient_obj.contains_key("title"), + "ingredient should have title" + ); + assert!( + ingredient_obj.contains_key("format"), + "ingredient should have format" + ); + + // Verify all hashes in ingredient are base64 strings, not byte arrays + if let Some(c2pa_manifest) = ingredient_obj.get("c2pa_manifest") { + if let Some(hash) = c2pa_manifest.get("hash") { + assert!( + hash.is_string(), + "ingredient c2pa_manifest hash should be string" + ); + } + } + + if let Some(thumbnail) = ingredient_obj.get("thumbnail") { + if let Some(hash) = thumbnail.get("hash") { + assert!(hash.is_string(), "ingredient thumbnail hash should be string"); + } + } + + Ok(()) +} + +#[test] +fn test_ingredient_count_matches() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Count ingredient assertions + let ingredient_count = assertions + .keys() + .filter(|k| k.starts_with("c2pa.ingredient")) + .count(); + + // CA.jpg has 1 ingredient (A.jpg as parent) + assert_eq!( + ingredient_count, 1, + "Should have exactly 1 ingredient assertion" + ); + + Ok(()) +} + +#[test] +fn test_ingredient_referenced_in_claim() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + // Find a manifest with assertions: either claim.v2 (created/gathered) or claim v1 (assertions array) + let active_manifest = manifests + .iter() + .find(|m| { + if let Some(claim_v2) = m.get("claim.v2") { + let has_created = claim_v2.get("created_assertions") + .and_then(|a| a.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false); + let has_gathered = claim_v2.get("gathered_assertions") + .and_then(|a| a.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false); + has_created || has_gathered + } else if let Some(claim_v1) = m.get("claim") { + claim_v1.get("assertions") + .and_then(|a| a.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false) + } else { + false + } + }) + .expect("should have at least one manifest with assertions"); + + // Check if ingredient is referenced in created_assertions (v2) or assertions (v1) + let (created_assertions, gathered_assertions) = if let Some(claim_v2) = active_manifest.get("claim.v2").and_then(|v| v.as_object()) { + ( + claim_v2["created_assertions"].as_array().expect("created_assertions should be array"), + claim_v2["gathered_assertions"].as_array().expect("gathered_assertions should be array"), + ) + } else { + // V1: single "assertions" array; treat as both created and gathered for this check + let assertions = active_manifest.get("claim").and_then(|c| c.get("assertions")).and_then(|a| a.as_array()).expect("claim.assertions should be array"); + (assertions, assertions) + }; + + // Find ingredient reference in EITHER created or gathered + let has_ingredient_in_created = created_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("c2pa.ingredient")) + .unwrap_or(false) + } else { + false + } + }); + + let has_ingredient_in_gathered = gathered_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("c2pa.ingredient")) + .unwrap_or(false) + } else { + false + } + }); + + // Ingredient should be in one or the other + assert!( + has_ingredient_in_created || has_ingredient_in_gathered, + "Ingredient should be referenced in either created_assertions or gathered_assertions" + ); + + Ok(()) +} + +#[test] +fn test_ingredient_in_actions_parameter() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check actions assertion for ingredient reference + if let Some(actions_assertion) = assertions.get("c2pa.actions.v2") { + let actions = actions_assertion["actions"] + .as_array() + .expect("actions should be array"); + + // Find action with ingredient parameter + let has_ingredient_param = actions.iter().any(|action| { + action + .get("parameters") + .and_then(|p| p.get("ingredient")) + .is_some() + }); + + assert!( + has_ingredient_param, + "At least one action should have ingredient parameter" + ); + } + + Ok(()) +} + +#[test] +fn test_multiple_ingredients_have_instances() -> Result<()> { + // Note: CA.jpg only has 1 ingredient, so this test verifies the instance logic + // For files with multiple ingredients, they would be labeled: + // c2pa.ingredient__1, c2pa.ingredient__2, etc. + + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // For single ingredient, should be just "c2pa.ingredient" + assert!( + assertions.contains_key("c2pa.ingredient"), + "Single ingredient should be c2pa.ingredient without instance" + ); + + // Should NOT have instance suffix for single ingredient + assert!( + !assertions.contains_key("c2pa.ingredient__1"), + "Single ingredient should not have instance number" + ); + + Ok(()) +} + +#[test] +fn test_ingredient_label_matches_version() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Get the ingredient assertion (could be c2pa.ingredient, c2pa.ingredient.v2, or c2pa.ingredient.v3) + let ingredient_key = assertions + .keys() + .find(|k| k.starts_with("c2pa.ingredient")) + .expect("Should have an ingredient assertion"); + + // Get the ingredient object + let ingredient = &assertions[ingredient_key]; + let ingredient_obj = ingredient.as_object().expect("ingredient should be object"); + + // Verify the label field is NOT present (it's redundant since the key is the label) + assert!( + !ingredient_obj.contains_key("label"), + "Ingredient should not have redundant label field" + ); + + // Verify the key follows correct versioning pattern + assert!( + ingredient_key == "c2pa.ingredient" + || ingredient_key.starts_with("c2pa.ingredient.v"), + "Ingredient key should be c2pa.ingredient or c2pa.ingredient.v{{N}}" + ); + + Ok(()) +} + diff --git a/sdk/tests/crjson/mod.rs b/sdk/tests/crjson/mod.rs new file mode 100644 index 000000000..4af4cc595 --- /dev/null +++ b/sdk/tests/crjson/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +mod schema_compliance; +mod asset_hash; +mod created_gathered; +mod hash_assertions; +mod hash_encoding; +mod ingredients; diff --git a/sdk/tests/crjson/schema_compliance.rs b/sdk/tests/crjson/schema_compliance.rs new file mode 100644 index 000000000..554e61076 --- /dev/null +++ b/sdk/tests/crjson/schema_compliance.rs @@ -0,0 +1,479 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +//! Schema compliance tests for crJSON format. +//! These tests validate CrJSON output structure and alignment with `cli/schemas/crJSON-schema.json`. +//! +//! **Reviewing generated crJSON when tests run:** set the environment variable +//! `C2PA_WRITE_CRJSON=1` (or any value), then run the crjson tests. Generated crJSON +//! for the fixture (CA.jpg) will be written to `target/crjson_test_output/` under +//! the build target directory (e.g. `target/crjson_test_output/CA.jpg.json` when +//! running from the workspace root, or `sdk/target/crjson_test_output/CA.jpg.json` +//! when building from the sdk directory). Example: +//! +//! ```sh +//! C2PA_WRITE_CRJSON=1 cargo test crjson +//! # then open target/crjson_test_output/CA.jpg.json +//! ``` + +use c2pa::{CrJsonReader, Result}; +use std::io::Cursor; + +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); + +/// CrJSON schema (cli/schemas/crJSON-schema.json) - used to verify output structure. +const CRJSON_SCHEMA: &str = include_str!("../../../cli/schemas/crJSON-schema.json"); + +/// When C2PA_WRITE_CRJSON is set, write generated crJSON to target/crjson_test_output/ +/// so you can review the exact output. Called at the start of tests that build CrJsonReader. +fn maybe_write_crjson_output(name: &str, json: &str) { + if std::env::var("C2PA_WRITE_CRJSON").is_ok() { + let out_dir = std::path::PathBuf::from("target/crjson_test_output"); + let _ = std::fs::create_dir_all(&out_dir); + let path = out_dir.join(name); + let _ = std::fs::write(&path, json); + eprintln!("CrJSON written to {:?} (C2PA_WRITE_CRJSON=1)", path); + } +} + +#[test] +fn test_validation_results_schema_compliance() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + maybe_write_crjson_output("CA.jpg.json", &reader.json()); + + let json_value = reader.to_json_value()?; + + // Document-level validationInfo (summary + validationTime) + if let Some(validation_info) = json_value.get("validationInfo") { + assert!( + validation_info.is_object(), + "validationInfo should be an object" + ); + let vi = validation_info.as_object().unwrap(); + if let Some(signature) = vi.get("signature") { + assert!(signature.is_array(), "validationInfo.signature should be array of status codes"); + for item in signature.as_array().unwrap() { + assert!(item.is_string(), "validationInfo.signature items should be strings"); + } + } + for key in &["trust", "content", "validationTime"] { + if let Some(v) = vi.get(*key) { + assert!(v.is_string(), "validationInfo.{} should be string", key); + } + } + } + + // Per-manifest validationResults (statusCodes: success, informational, failure) and optional ingredientDeltas + let manifests = json_value + .get("manifests") + .and_then(|m| m.as_array()) + .expect("manifests should exist"); + if let Some(first) = manifests.first() { + let vr = first + .get("validationResults") + .expect("manifest must have validationResults per crJSON schema"); + assert!(vr.is_object(), "validationResults should be object"); + let vr_obj = vr.as_object().unwrap(); + for key in &["success", "informational", "failure"] { + let arr = vr_obj + .get(*key) + .unwrap_or_else(|| panic!("validationResults must have {} array per schema", key)); + assert!(arr.is_array(), "{} should be array", key); + for entry in arr.as_array().unwrap() { + assert!(entry.is_object(), "Each entry should be object"); + let obj = entry.as_object().unwrap(); + assert!( + obj.contains_key("code"), + "Entry should have code (validationStatusEntry)" + ); + assert!(obj.get("code").unwrap().is_string(), "code should be string"); + } + } + // Optional: per-manifest ingredientDeltas + if let Some(deltas) = first.get("ingredientDeltas") { + assert!(deltas.is_array(), "manifest ingredientDeltas should be array"); + for item in deltas.as_array().unwrap() { + let obj = item.as_object().expect("Each delta should be object"); + assert!(obj.contains_key("ingredientAssertionURI"), "Delta should have ingredientAssertionURI"); + assert!(obj.contains_key("validationDeltas"), "Delta should have validationDeltas"); + } + } + } + + Ok(()) +} + +#[test] +fn test_manifest_validation_and_status_schema_compliance() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Document-level validationInfo (signature array, trust, content, validationTime) + if let Some(validation_info) = json_value.get("validationInfo") { + assert!(validation_info.is_object(), "validationInfo should be an object"); + let info_obj = validation_info.as_object().unwrap(); + + if let Some(signature) = info_obj.get("signature") { + assert!(signature.is_array(), "signature should be array of status codes"); + for item in signature.as_array().unwrap() { + assert!(item.is_string(), "signature items should be strings"); + } + } + if let Some(trust) = info_obj.get("trust") { + assert!(trust.is_string(), "trust status should be string"); + } + if let Some(content) = info_obj.get("content") { + assert!(content.is_string(), "content status should be string"); + } + if let Some(validation_time) = info_obj.get("validationTime") { + assert!(validation_time.is_string(), "validationTime should be string"); + } + } + + // Each manifest has validationResults (statusCodes) + let manifests = json_value + .get("manifests") + .expect("manifests should exist") + .as_array() + .expect("manifests should be an array"); + for manifest in manifests { + let vr = manifest + .get("validationResults") + .expect("manifest should have validationResults"); + assert!(vr.is_object(), "validationResults should be object"); + let vr_obj = vr.as_object().unwrap(); + assert!(vr_obj.contains_key("success")); + assert!(vr_obj.contains_key("informational")); + assert!(vr_obj.contains_key("failure")); + } + + Ok(()) +} + +#[test] +fn test_context_schema_compliance() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Verify @context exists + let context = json_value.get("@context").expect("@context should exist"); + + // @context can be array of URIs or object + assert!( + context.is_object() || context.is_array(), + "@context should be object or array" + ); + + // If object, should have @vocab property + if let Some(context_obj) = context.as_object() { + if let Some(vocab) = context_obj.get("@vocab") { + assert!(vocab.is_string(), "@vocab should be string"); + } + } + + Ok(()) +} + +#[test] +fn test_manifests_array_schema_compliance() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Verify manifests is an array + let manifests = json_value.get("manifests").expect("manifests should exist"); + + assert!(manifests.is_array(), "manifests should be an array"); + + // Check each manifest (schema required: label, assertions, signature, validationResults; oneOf: claim or claim.v2) + for manifest in manifests.as_array().unwrap() { + assert!(manifest.is_object(), "Each manifest should be an object"); + let manifest_obj = manifest.as_object().unwrap(); + + // Required: label + let label = manifest_obj.get("label").expect("manifest should have label"); + assert!(label.is_string(), "label should be string"); + + // Required: assertions (object, not array) + let assertions = manifest_obj + .get("assertions") + .expect("manifest should have assertions"); + assert!( + assertions.is_object(), + "assertions should be object, not array" + ); + + // Required: signature (object with optional algorithm, issuer, etc.) + let signature = manifest_obj + .get("signature") + .expect("manifest should have signature"); + assert!(signature.is_object(), "signature should be object"); + + // Required: validationResults (statusCodes object) + let validation_results = manifest_obj + .get("validationResults") + .expect("manifest should have validationResults"); + assert!(validation_results.is_object(), "validationResults should be object"); + + // oneOf: either claim or claim.v2 (implementation emits claim.v2) + let has_claim = manifest_obj.get("claim").is_some(); + let has_claim_v2 = manifest_obj.get("claim.v2").is_some(); + assert!( + has_claim || has_claim_v2, + "manifest should have either claim or claim.v2" + ); + if let Some(claim_v2) = manifest_obj.get("claim.v2") { + assert!(claim_v2.is_object(), "claim.v2 should be object"); + // Per crJSON schema, claim.v2.claim_generator_info is a single object, not an array + if let Some(cgi) = claim_v2.get("claim_generator_info") { + assert!( + cgi.is_object(), + "claim.v2.claim_generator_info must be object per schema, got array or other" + ); + // When present, icon must be hashedUriMap (url, hash, optional alg) per schema + if let Some(icon) = cgi.get("icon") { + assert!(icon.is_object(), "claim_generator_info.icon must be object (hashedUriMap)"); + let icon_obj = icon.as_object().unwrap(); + assert!( + icon_obj.get("url").and_then(|v| v.as_str()).is_some(), + "claim_generator_info.icon must have string 'url' (hashedUriMap)" + ); + assert!( + icon_obj.get("hash").and_then(|v| v.as_str()).is_some(), + "claim_generator_info.icon must have string 'hash' (hashedUriMap)" + ); + } + } + } + } + + Ok(()) +} + +#[test] +fn test_complete_schema_structure() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + let json_value = reader.to_json_value()?; + + // Verify all top-level required fields (no asset_info, content, or metadata) + assert!(json_value.get("@context").is_some(), "@context missing"); + assert!(json_value.get("manifests").is_some(), "manifests missing"); + // validationInfo present when validation was run; manifests have validationResults + assert!(json_value.get("validationInfo").is_some(), "validationInfo missing"); + + // CrJSON does not include asset_info, content, or metadata + assert!(json_value.get("asset_info").is_none()); + assert!(json_value.get("content").is_none()); + assert!(json_value.get("metadata").is_none()); + + // Verify types + assert!(json_value["@context"].is_object()); + assert!(json_value["manifests"].is_array()); + assert!(json_value["validationInfo"].is_object()); + + Ok(()) +} + +/// Load and parse the CrJSON schema file; ensure it defines the expected root properties +/// and does not include declaration, asset_info, content, or metadata. +#[test] +fn test_cr_json_schema_file_valid_and_matches_format() -> Result<()> { + let schema_value: serde_json::Value = + serde_json::from_str(CRJSON_SCHEMA).expect("crJSON-schema.json must be valid JSON"); + + let props = schema_value + .get("properties") + .and_then(|p| p.as_object()) + .expect("schema must have properties"); + + // CrJSON schema must define these root properties + assert!(props.contains_key("@context"), "schema must define @context"); + assert!(props.contains_key("manifests"), "schema must define manifests"); + assert!( + props.contains_key("validationInfo"), + "schema must define validationInfo" + ); + + // Manifest definition must allow ingredientDeltas (per-manifest) + let definitions = schema_value.get("definitions").and_then(|d| d.as_object()).expect("schema must have definitions"); + let manifest_def = definitions.get("manifest").and_then(|m| m.as_object()).expect("schema must define manifest"); + let manifest_props = manifest_def.get("properties").and_then(|p| p.as_object()).expect("manifest must have properties"); + assert!(manifest_props.contains_key("ingredientDeltas"), "manifest must define ingredientDeltas (per-manifest)"); + + // CrJSON schema must NOT include removed sections + assert!(!props.contains_key("declaration"), "schema must not include declaration"); + assert!(!props.contains_key("asset_info"), "schema must not include asset_info"); + assert!(!props.contains_key("content"), "schema must not include content"); + assert!(!props.contains_key("metadata"), "schema must not include metadata"); + + // Schema $id should reference crJSON-schema + let id = schema_value.get("$id").and_then(|i| i.as_str()).unwrap_or(""); + assert!( + id.contains("crJSON-schema"), + "schema $id should reference crJSON-schema.json, got: {}", + id + ); + + Ok(()) +} + +/// Verify CrJSON output from the reader conforms to the schema's root shape +/// (no declaration, asset_info, content, metadata). +#[test] +fn test_cr_json_output_matches_schema_root() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let schema_value: serde_json::Value = + serde_json::from_str(CRJSON_SCHEMA).expect("crJSON-schema.json must be valid JSON"); + let props = schema_value + .get("properties") + .and_then(|p| p.as_object()) + .expect("schema must have properties"); + + // Every top-level key in output should be allowed by the schema (or be additionalProperties) + for key in json_value.as_object().unwrap().keys() { + assert!( + props.contains_key(key), + "CrJSON output key {:?} is not in crJSON-schema.json properties (schema may allow via additionalProperties)", + key + ); + } + + // Output must not contain removed root keys + assert!(json_value.get("declaration").is_none()); + assert!(json_value.get("asset_info").is_none()); + assert!(json_value.get("content").is_none()); + assert!(json_value.get("metadata").is_none()); + + Ok(()) +} + +/// Schema must define validationResults (used by ingredient assertions, e.g. c2pa.ingredient.v3). +/// When crJSON output contains an ingredient assertion with validationResults, it must match that definition. +#[test] +fn test_validation_results_definition_and_ingredient_usage() -> Result<()> { + let schema_value: serde_json::Value = + serde_json::from_str(CRJSON_SCHEMA).expect("crJSON-schema.json must be valid JSON"); + let definitions = schema_value + .get("definitions") + .and_then(|d| d.as_object()) + .expect("schema must have definitions"); + + // validationResults definition must exist (used by manifest-level statusCodes and by ingredientAssertionV3) + let validation_results_def = definitions + .get("validationResults") + .and_then(|v| v.as_object()) + .expect("schema must define validationResults for use by ingredient assertions"); + assert_eq!( + validation_results_def + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or(""), + "object", + "validationResults must be type object" + ); + let vr_props = validation_results_def + .get("properties") + .and_then(|p| p.as_object()) + .expect("validationResults must have properties"); + assert!( + vr_props.contains_key("activeManifest"), + "validationResults must have activeManifest (statusCodes)" + ); + assert!( + vr_props.contains_key("ingredientDeltas"), + "validationResults must have ingredientDeltas" + ); + let vr_required = validation_results_def + .get("required") + .and_then(|r| r.as_array()) + .expect("validationResults must have required array"); + assert!( + vr_required.iter().any(|v| v.as_str() == Some("activeManifest")), + "validationResults.required must include activeManifest" + ); + + // ingredientAssertionV3 must reference validationResults + let ingredient_v3 = definitions + .get("ingredientAssertionV3") + .and_then(|v| v.as_object()) + .expect("schema must define ingredientAssertionV3"); + let v3_props = ingredient_v3 + .get("properties") + .and_then(|p| p.as_object()) + .expect("ingredientAssertionV3 must have properties"); + let vr_ref = v3_props + .get("validationResults") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("$ref")) + .and_then(|r| r.as_str()) + .expect("ingredientAssertionV3.validationResults must have $ref to validationResults"); + assert_eq!( + vr_ref, + "#/definitions/validationResults", + "ingredientAssertionV3.validationResults must $ref #/definitions/validationResults" + ); + + // When crJSON output contains an ingredient assertion with validationResults, validate its shape + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + let manifests = json_value + .get("manifests") + .and_then(|m| m.as_array()) + .expect("manifests should exist"); + for manifest in manifests { + let assertions = match manifest.get("assertions").and_then(|a| a.as_object()) { + Some(a) => a, + None => continue, + }; + for (_key, assertion_value) in assertions { + let assertion_obj = match assertion_value.as_object() { + Some(o) => o, + None => continue, + }; + if let Some(ingredient_vr) = assertion_obj.get("validationResults") { + // This assertion has validationResults (e.g. v3 ingredient) - must match validationResults definition + let vr = ingredient_vr + .as_object() + .expect("ingredient validationResults must be object"); + let active_manifest = vr + .get("activeManifest") + .expect("ingredient validationResults must have activeManifest per schema"); + let am = active_manifest + .as_object() + .expect("activeManifest must be object (statusCodes)"); + for key in &["success", "informational", "failure"] { + assert!( + am.contains_key(*key), + "ingredient validationResults.activeManifest must have {} array", + key + ); + assert!( + am.get(*key).unwrap().as_array().is_some(), + "ingredient validationResults.activeManifest.{} must be array", + key + ); + } + if let Some(deltas) = vr.get("ingredientDeltas") { + assert!( + deltas.is_array(), + "ingredient validationResults.ingredientDeltas must be array" + ); + } + } + } + } + + Ok(()) +} diff --git a/sdk/tests/crjson_tests.rs b/sdk/tests/crjson_tests.rs new file mode 100644 index 000000000..cbb724292 --- /dev/null +++ b/sdk/tests/crjson_tests.rs @@ -0,0 +1,17 @@ +// Copyright 2024 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +//! CrJSON integration tests. +//! Tests are organized in the crjson/ subfolder. + +mod crjson;