Skip to content

Commit

Permalink
refactor ingredient assertion serialization
Browse files Browse the repository at this point in the history
  • Loading branch information
karlobencic committed Jan 8, 2025
1 parent 2d6822c commit 4afcb18
Show file tree
Hide file tree
Showing 3 changed files with 416 additions and 66 deletions.
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default tseslint.config(
'@typescript-eslint/no-unused-vars': ['error', { args: 'none' }],
'@typescript-eslint/prefer-nullish-coalescing': ['error', { ignoreConditionalTests: true }],
'no-console': 'error',
'complexity': ['warn', { max: 20 }],
'complexity': ['warn', { max: 24 }],
},
},
);
283 changes: 229 additions & 54 deletions src/manifest/assertions/IngredientAssertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { ValidationResult } from '../ValidationResult';
import { Assertion } from './Assertion';
import { AssertionLabels } from './AssertionLabels';

const ASSERTION_CREATION_VERSION = 3;

interface RawIngredientMapV1 {
'dc:title': string;
'dc:format': string;
Expand All @@ -22,14 +24,16 @@ interface RawIngredientMapV1 {
metadata?: raw.AssertionMetadataMap;
}

interface RawIngredientMapV2 extends Omit<RawIngredientMapV1, 'validationStatus' | 'c2pa_manifest'> {
interface RawIngredientMapV2 extends Omit<RawIngredientMapV1, 'validationStatus' | 'instanceID'> {
instanceID?: string;
data?: raw.HashedURI;
dataTypes?: { type: string; value?: string }[];
informational_URI?: string;
description?: string;
}

interface RawIngredientMapV3
extends Omit<RawIngredientMapV2, 'dc:title' | 'dc:format' | 'informational_URI' | 'documentID'> {
extends Omit<RawIngredientMapV2, 'dc:title' | 'dc:format' | 'informational_URI' | 'documentID' | 'c2pa_manifest'> {
'dc:title'?: string;
'dc:format'?: string;
activeManifest?: raw.HashedURI;
Expand All @@ -50,12 +54,12 @@ interface RawIngredientMapV3
};
claimSignature?: raw.HashedURI;
informationalURI?: string;
description?: string;
}

export class IngredientAssertion extends Assertion {
public label = AssertionLabels.ingredient;
public uuid = raw.UUIDs.cborAssertion;
public version: number = ASSERTION_CREATION_VERSION;

public title?: string;
public format?: string;
Expand All @@ -74,6 +78,31 @@ export class IngredientAssertion extends Assertion {
public validationStatus?: ValidationStatusCode[];
public c2pa_manifest?: HashedURI;

public static new(title: string, format: string, instanceId: string, documentId?: string): IngredientAssertion {
const assertion = new IngredientAssertion();
assertion.version = 1;
assertion.title = title;
assertion.format = format;
assertion.instanceID = instanceId;
assertion.documentID = documentId;
return assertion;
}

public static newV2(title: string, format: string): IngredientAssertion {
const assertion = new IngredientAssertion();
assertion.version = 2;
assertion.title = title;
assertion.format = format;
return assertion;
}

public static newV3(relationship: RelationshipType): IngredientAssertion {
const assertion = new IngredientAssertion();
assertion.version = 3;
assertion.relationship = relationship;
return assertion;
}

public isV1Compatible(): boolean {
return (
this.title !== undefined &&
Expand Down Expand Up @@ -103,6 +132,101 @@ export class IngredientAssertion extends Assertion {
return this.documentID === undefined && this.validationStatus === undefined && this.c2pa_manifest === undefined;
}

private serializeV1(claim: Claim): RawIngredientMapV1 {
if (!this.relationship) throw new Error('Assertion has no relationship');

const content: RawIngredientMapV1 = {
'dc:title': this.title!,
'dc:format': this.format!,
instanceID: this.instanceID!,
relationship: this.relationship,
};

if (this.documentID) content.documentID = this.documentID;
if (this.c2pa_manifest) content.c2pa_manifest = claim.reverseMapHashedURI(this.c2pa_manifest);
if (this.thumbnail) content.thumbnail = claim.reverseMapHashedURI(this.thumbnail);
if (this.validationStatus) content.validationStatus = this.validationStatus;
if (this.metadata) content.metadata = this.metadata;

return content;
}

private serializeV2(claim: Claim): RawIngredientMapV2 {
if (!this.relationship) throw new Error('Assertion has no relationship');

const content: RawIngredientMapV2 = {
'dc:title': this.title!,
'dc:format': this.format!,
instanceID: this.instanceID!,
relationship: this.relationship,
};

if (this.documentID) content.documentID = this.documentID;
if (this.c2pa_manifest) content.c2pa_manifest = claim.reverseMapHashedURI(this.c2pa_manifest);
if (this.data) content.data = claim.reverseMapHashedURI(this.data);
if (this.dataTypes?.length) content.dataTypes = this.dataTypes;
if (this.thumbnail) content.thumbnail = claim.reverseMapHashedURI(this.thumbnail);
if (this.description) content.description = this.description;
if (this.informationalURI) content.informational_URI = this.informationalURI;
if (this.metadata) content.metadata = this.metadata;

return content;
}

private serializeV3(claim: Claim): RawIngredientMapV3 {
if (!this.relationship) throw new Error('Assertion has no relationship');

if ((!this.activeManifest && this.validationResults) || (this.activeManifest && !this.validationResults)) {
throw new Error('Ingredient has incompatible fields');
}

const content: RawIngredientMapV3 = {
relationship: this.relationship,
};

if (this.title) content['dc:title'] = this.title;
if (this.format) content['dc:format'] = this.format;
if (this.instanceID) content.instanceID = this.instanceID;
if (this.validationResults) content.validationResults = this.validationResults;
if (this.data) content.data = claim.reverseMapHashedURI(this.data);
if (this.dataTypes?.length) content.dataTypes = this.dataTypes;
if (this.activeManifest) content.activeManifest = claim.reverseMapHashedURI(this.activeManifest);
if (this.claimSignature) content.claimSignature = claim.reverseMapHashedURI(this.claimSignature);
if (this.thumbnail) content.thumbnail = claim.reverseMapHashedURI(this.thumbnail);
if (this.description) content.description = this.description;
if (this.informationalURI) content.informationalURI = this.informationalURI;
if (this.metadata) content.metadata = this.metadata;

return content;
}

/**
* Generates a JUMBF box containing this assertion's content
* @param claim - The claim this assertion belongs to
* @returns The generated JUMBF box
* @throws Error if required fields are missing
*/
public generateJUMBFBoxForContent(claim: Claim): JUMBF.IBox {
let content;
switch (this.version) {
case 1:
content = this.serializeV1(claim);
break;
case 2:
content = this.serializeV2(claim);
break;
case 3:
content = this.serializeV3(claim);
break;
default:
throw new Error('Unsupported ingredient version');
}

const box = new JUMBF.CBORBox();
box.content = content;
return box;
}

/**
* Reads the content of this assertion from a JUMBF box
* @param box - The JUMBF box to read from
Expand All @@ -117,80 +241,131 @@ export class IngredientAssertion extends Assertion {
'Ingredient assertion has invalid type',
);

const content = box.content as RawIngredientMapV3 & RawIngredientMapV2 & RawIngredientMapV1;
const content = box.content;

if (!content.relationship)
// Determine version based on fields present
if (this.isV3Content(content)) {
this.version = 3;
this.readV3Content(content, claim);
} else if (this.isV2Content(content)) {
this.version = 2;
this.readV2Content(content, claim);
} else {
this.version = 1;
this.readV1Content(content as RawIngredientMapV1, claim);
}
}

private isV3Content(content: unknown): content is RawIngredientMapV3 {
return (
typeof content === 'object' &&
content !== null &&
'relationship' in content &&
!('documentID' in content) &&
!('validationStatus' in content) &&
!('c2pa_manifest' in content) &&
('validationResults' in content || 'activeManifest' in content || 'claimSignature' in content)
);
}

private isV2Content(content: unknown): content is RawIngredientMapV2 {
return (
typeof content === 'object' &&
content !== null &&
'dc:title' in content &&
'dc:format' in content &&
!('validationResults' in content) &&
!('activeManifest' in content) &&
!('claimSignature' in content) &&
('data' in content || 'dataTypes' in content || 'informational_URI' in content || 'description' in content)
);
}

private readV1Content(content: RawIngredientMapV1, claim: Claim): void {
if (!content.relationship) {
throw new ValidationError(
ValidationStatusCode.AssertionCBORInvalid,
this.sourceBox,
'Ingredient assertion is missing a relationship',
);
}

// Mandatory fields
this.title = content['dc:title'];
this.format = content['dc:format'];
this.instanceID = content.instanceID;
this.relationship = content.relationship;

if ('activeManifest' in content && content.activeManifest) {
this.activeManifest = claim.mapHashedURI(content.activeManifest);
} else if ('c2pa_manifest' in content && content.c2pa_manifest) {
this.activeManifest = claim.mapHashedURI(content.c2pa_manifest);
}

if ('documentID' in content && content.documentID) {
this.documentID = content.documentID;
}

// Optional fields
if (content.documentID) this.documentID = content.documentID;
if (content.c2pa_manifest) this.c2pa_manifest = claim.mapHashedURI(content.c2pa_manifest);
if (content.thumbnail) this.thumbnail = claim.mapHashedURI(content.thumbnail);
if (content.dataTypes) this.dataTypes = content.dataTypes;
if (content.claimSignature) this.claimSignature = claim.mapHashedURI(content.claimSignature);
if (content.validationResults) this.validationResults = content.validationResults;
if (content.data) this.data = claim.mapHashedURI(content.data);
if (content.validationStatus) this.validationStatus = content.validationStatus;
if (content.metadata) this.metadata = content.metadata;
}

if ('informationalURI' in content) {
this.informationalURI = content.informationalURI;
} else if ('informational_URI' in content && typeof content.informational_URI === 'string') {
this.informationalURI = content.informational_URI;
private readV2Content(content: RawIngredientMapV2, claim: Claim): void {
if (!content.relationship) {
throw new ValidationError(
ValidationStatusCode.AssertionCBORInvalid,
this.sourceBox,
'Ingredient assertion is missing a relationship',
);
}

// Mandatory fields
this.title = content['dc:title'];
this.format = content['dc:format'];
this.relationship = content.relationship;

// Optional fields
if (content.instanceID) this.instanceID = content.instanceID;
if (content.documentID) this.documentID = content.documentID;
if (content.data) this.data = claim.mapHashedURI(content.data);
if (content.dataTypes) this.dataTypes = content.dataTypes;
if (content.thumbnail) this.thumbnail = claim.mapHashedURI(content.thumbnail);
if (content.description) this.description = content.description;
if (content.informational_URI) this.informationalURI = content.informational_URI;
if (content.metadata) this.metadata = content.metadata;
}

/**
* Generates a JUMBF box containing this assertion's content
* @param claim - The claim this assertion belongs to
* @returns The generated JUMBF box
* @throws Error if required fields are missing
*/
public generateJUMBFBoxForContent(claim: Claim): JUMBF.IBox {
if (!this.relationship) throw new Error('Assertion has no relationship');

const content: RawIngredientMapV3 | RawIngredientMapV2 | RawIngredientMapV1 = {
instanceID: this.instanceID!,
relationship: this.relationship,
};
private readV3Content(content: RawIngredientMapV3, claim: Claim): void {
if (!content.relationship) {
throw new ValidationError(
ValidationStatusCode.AssertionCBORInvalid,
this.sourceBox,
'Ingredient assertion is missing a relationship',
);
}

if (this.activeManifest) content.activeManifest = claim.reverseMapHashedURI(this.activeManifest);
if (this.thumbnail) content.thumbnail = claim.reverseMapHashedURI(this.thumbnail);
if (this.dataTypes?.length) content.dataTypes = this.dataTypes;
if (this.claimSignature) content.claimSignature = claim.reverseMapHashedURI(this.claimSignature);
if (this.validationResults) content.validationResults = this.validationResults;
if (this.informationalURI) content.informationalURI = this.informationalURI;
if (this.data) content.data = claim.reverseMapHashedURI(this.data);
if (this.description) content.description = this.description;
if (this.title) content['dc:title'] = this.title;
if (this.format) content['dc:format'] = this.format;
if (this.metadata) content.metadata = this.metadata;
if (this.documentID) (content as RawIngredientMapV2).documentID = this.documentID;
if (this.validationStatus) (content as RawIngredientMapV1).validationStatus = this.validationStatus;
if (this.c2pa_manifest)
(content as RawIngredientMapV1).c2pa_manifest = claim.reverseMapHashedURI(this.c2pa_manifest);
// Check for incompatible fields
if (
(!content.activeManifest && content.validationResults) ||
(content.activeManifest && !content.validationResults)
) {
throw new ValidationError(
ValidationStatusCode.AssertionCBORInvalid,
this.sourceBox,
'Ingredient has incompatible fields',
);
}

const box = new JUMBF.CBORBox();
box.content = content;
// Mandatory field
this.relationship = content.relationship;

return box;
// Optional fields
if (content['dc:title']) this.title = content['dc:title'];
if (content['dc:format']) this.format = content['dc:format'];
if (content.instanceID) this.instanceID = content.instanceID;
if (content.validationResults) this.validationResults = content.validationResults;
if (content.data) this.data = claim.mapHashedURI(content.data);
if (content.dataTypes) this.dataTypes = content.dataTypes;
if (content.activeManifest) this.activeManifest = claim.mapHashedURI(content.activeManifest);
if (content.claimSignature) this.claimSignature = claim.mapHashedURI(content.claimSignature);
if (content.thumbnail) this.thumbnail = claim.mapHashedURI(content.thumbnail);
if (content.description) this.description = content.description;
if (content.informationalURI) this.informationalURI = content.informationalURI;
if (content.metadata) this.metadata = content.metadata;
}

/**
Expand Down
Loading

0 comments on commit 4afcb18

Please sign in to comment.