From dcb74b1a4300b2b12066f580115ba3e6fa1c18a3 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Wed, 25 Mar 2026 20:38:46 +0000 Subject: [PATCH] Add manifest validation and enhanced assertion/ingredient types --- Library/Sources/Builder.swift | 37 +- Library/Sources/C2PAError.swift | 9 + Library/Sources/C2PAJson.swift | 2 +- Library/Sources/Intent.swift | 2 +- Library/Sources/Manifest/Action.swift | 177 ++++++- .../Manifest/AssertionDefinition.swift | 350 +++++++++---- Library/Sources/Manifest/AssetType.swift | 2 +- Library/Sources/Manifest/Coordinate.swift | 2 +- .../Sources/Manifest/DigitalSourceType.swift | 2 +- Library/Sources/Manifest/Frame.swift | 2 +- .../Sources/Manifest/ImageRegionType.swift | 26 +- Library/Sources/Manifest/Ingredient.swift | 41 ++ .../IngredientDeltaValidationResult.swift | 2 +- Library/Sources/Manifest/Item.swift | 2 +- .../Sources/Manifest/ManifestDefinition.swift | 139 ++++- .../Manifest/ManifestValidationResult.swift | 63 +++ .../Sources/Manifest/ManifestValidator.swift | 163 ++++++ .../Sources/Manifest/PredefinedAction.swift | 59 ++- Library/Sources/Manifest/RangeType.swift | 2 +- Library/Sources/Manifest/RegionRange.swift | 2 +- Library/Sources/Manifest/Relationship.swift | 2 +- Library/Sources/Manifest/ReviewRating.swift | 2 +- Library/Sources/Manifest/Role.swift | 2 +- Library/Sources/Manifest/Shape.swift | 2 +- Library/Sources/Manifest/ShapeType.swift | 2 +- .../Manifest/StandardAssertionLabel.swift | 10 +- Library/Sources/Manifest/StatusCodes.swift | 2 +- Library/Sources/Manifest/Text.swift | 2 +- Library/Sources/Manifest/TextSelector.swift | 2 +- .../Sources/Manifest/TextSelectorRange.swift | 2 +- Library/Sources/Manifest/Time.swift | 2 +- Library/Sources/Manifest/TimeType.swift | 2 +- Library/Sources/Manifest/UnitType.swift | 2 +- .../Sources/Manifest/ValidationResults.swift | 2 +- .../Sources/Manifest/ValidationStatus.swift | 2 +- .../Manifest/ValidationStatusCode.swift | 345 ++++++++++--- Library/Sources/Reader.swift | 2 +- Library/Sources/SignerInfo.swift | 2 +- Library/Tests/LibraryTestRunner.swift | 105 ++++ TestApp/Sources/TestRunner.swift | 14 +- .../Sources/AssertionDefinitionTests.swift | 195 +++++++ TestShared/Sources/ManifestTests.swift | 480 +++++++++++++++++- 42 files changed, 2049 insertions(+), 216 deletions(-) create mode 100644 Library/Sources/Manifest/ManifestValidationResult.swift create mode 100644 Library/Sources/Manifest/ManifestValidator.swift diff --git a/Library/Sources/Builder.swift b/Library/Sources/Builder.swift index 7758403..3965b09 100644 --- a/Library/Sources/Builder.swift +++ b/Library/Sources/Builder.swift @@ -24,6 +24,7 @@ import Foundation /// ## Topics /// /// ### Creating a Builder +/// - ``init(manifest:)`` /// - ``init(manifestJSON:)`` /// - ``init(archiveStream:)`` /// @@ -65,13 +66,45 @@ import Foundation public final class Builder { private let ptr: UnsafeMutablePointer + /// Internal initializer that skips validation. + private init(validatedJSON: String) throws { + ptr = try guardNotNull(c2pa_builder_from_json(validatedJSON)) + } + + /// Validates a ``ManifestValidationResult``, logging warnings and throwing on errors. + private static func enforce(_ result: ManifestValidationResult) throws { + for warning in result.warnings { + NSLog("[C2PA] Manifest validation warning: %@", warning) + } + if result.hasErrors { + throw C2PAError.manifestValidationFailed(result) + } + } + + /// Creates a new builder from a ``ManifestDefinition``. + /// + /// Validates the manifest before construction. Errors cause a throw; + /// warnings are logged via `NSLog`. + /// + /// - Parameter manifest: The manifest definition to build. + /// + /// - Throws: ``C2PAError/manifestValidationFailed(_:)`` if validation finds errors, + /// or ``C2PAError`` if the JSON cannot be parsed by the C layer. + public convenience init(manifest: ManifestDefinition) throws { + try Self.enforce(ManifestValidator.validate(manifest)) + try self.init(validatedJSON: manifest.toJSON()) + } + /// Creates a new builder from a manifest JSON definition. /// + /// This is a low-level initializer that passes the JSON directly to the C layer. + /// Use ``init(manifest:)`` for automatic validation before construction. + /// /// - Parameter manifestJSON: A JSON string defining the C2PA manifest structure. /// /// - Throws: ``C2PAError`` if the JSON is invalid or cannot be parsed. - public init(manifestJSON: String) throws { - ptr = try guardNotNull(c2pa_builder_from_json(manifestJSON)) + public convenience init(manifestJSON: String) throws { + try self.init(validatedJSON: manifestJSON) } /// Creates a new builder from a previously created C2PA archive stream. diff --git a/Library/Sources/C2PAError.swift b/Library/Sources/C2PAError.swift index 77232c9..d456767 100644 --- a/Library/Sources/C2PAError.swift +++ b/Library/Sources/C2PAError.swift @@ -35,6 +35,7 @@ import Foundation /// - ``publicKeyExtractionFailed`` /// - ``publicKeyExportFailed(_:)`` /// - ``asyncSigningFailed`` +/// - ``manifestValidationFailed(_:)`` public enum C2PAError: Error, LocalizedError { /// An error reported by the underlying C2PA library. /// @@ -85,6 +86,11 @@ public enum C2PAError: Error, LocalizedError { case asyncSigningFailed + /// Manifest validation failed before building. + /// + /// - Parameter result: The ``ManifestValidationResult`` containing errors and warnings. + case manifestValidationFailed(_ result: ManifestValidationResult) + /// A human-readable description of the error. public var errorDescription: String? { switch self { @@ -129,6 +135,9 @@ public enum C2PAError: Error, LocalizedError { case .asyncSigningFailed: return "Async signing operation failed" + + case .manifestValidationFailed(let result): + return "Manifest validation failed: \(result.errors.joined(separator: "; "))" } } } diff --git a/Library/Sources/C2PAJson.swift b/Library/Sources/C2PAJson.swift index aa375b2..d813707 100644 --- a/Library/Sources/C2PAJson.swift +++ b/Library/Sources/C2PAJson.swift @@ -25,7 +25,7 @@ import Foundation /// let pretty = try C2PAJson.encodePretty(manifest) /// let decoded = try C2PAJson.decode(ManifestDefinition.self, from: jsonString) /// ``` -public enum C2PAJson { +public enum C2PAJson: Sendable { /// Shared encoder configured for C2PA JSON conventions. public static let encoder: JSONEncoder = { let encoder = JSONEncoder() diff --git a/Library/Sources/Intent.swift b/Library/Sources/Intent.swift index 82d876c..c640059 100644 --- a/Library/Sources/Intent.swift +++ b/Library/Sources/Intent.swift @@ -27,7 +27,7 @@ import Foundation /// - ``update`` /// /// - SeeAlso: ``Builder/setIntent(_:)`` -public enum BuilderIntent { +public enum BuilderIntent: Sendable { /// A new digital creation with the specified digital source type. /// /// Use this intent for assets that are being created for the first time, diff --git a/Library/Sources/Manifest/Action.swift b/Library/Sources/Manifest/Action.swift index 8a4ded3..f44dd68 100644 --- a/Library/Sources/Manifest/Action.swift +++ b/Library/Sources/Manifest/Action.swift @@ -13,10 +13,26 @@ import Foundation +/// An action performed on the asset, used in C2PA manifest assertions. +/// +/// In C2PA v2, `softwareAgent` may be either a plain string (v1 format) or a +/// `ClaimGeneratorInfo` object (v2 format). Use ``softwareAgentString`` or +/// ``softwareAgentInfo`` to access the value in the desired format. +/// /// - SeeAlso: [Actions Reference](https://opensource.contentauthenticity.org/docs/manifest/writing/assertions-actions#actions) - public struct Action: Codable, Equatable { + public enum CodingKeys: String, CodingKey { + case action + case digitalSourceType = "digital_source_type" + case softwareAgent = "software_agent" + case parameters + case when + case changes + case related + case reason + } + /// The action name. Most probably a ``PredefinedAction``. public var action: String @@ -24,43 +40,188 @@ public struct Action: Codable, Equatable { public var digitalSourceType: String? /// The software or hardware used to perform the action. - public var softwareAgent: String? + /// + /// In C2PA v1, this is a plain string. In v2, it can be a ``ClaimGeneratorInfo`` object. + /// Use ``softwareAgentString`` or ``softwareAgentInfo`` to access the typed value. + public var softwareAgent: AnyCodable? /// Additional information describing the action. - public var parameters: [String: String]? + public var parameters: [String: AnyCodable]? + + /// The timestamp when the action was performed (ISO 8601 format). + public var when: String? + + /// Regions of interest describing what changed. + public var changes: [RegionOfInterest]? + + /// Related ingredient labels. + public var related: [String]? + /// The reason for performing the action (e.g., "c2pa.PII.present"). + public var reason: String? + + // MARK: - Computed Properties + + /// Returns the softwareAgent as a string if it is a plain string value, nil otherwise. + public var softwareAgentString: String? { + softwareAgent?.value as? String + } + + /// Returns the softwareAgent as a ``ClaimGeneratorInfo`` if it is an object, nil otherwise. + public var softwareAgentInfo: ClaimGeneratorInfo? { + guard let dict = softwareAgent?.value as? [String: Any], + let data = try? JSONSerialization.data(withJSONObject: dict), + let info = try? JSONDecoder().decode(ClaimGeneratorInfo.self, from: data) + else { return nil } + return info + } + + // MARK: - Initializers + + /// Creates an action with full control over all fields. + /// /// - Parameters: /// - action: The action name. Most probably a ``PredefinedAction``. /// - digitalSourceType: A URL identifying an IPTC term. Most probably a ``DigitalSourceType``. - /// - softwareAgent: The software or hardware used to perform the action. Defaults to the app name. + /// - softwareAgent: The software or hardware used to perform the action (string or object). /// - parameters: Additional information describing the action. + /// - when: The timestamp when the action was performed (ISO 8601 format). + /// - changes: Regions of interest describing what changed. + /// - related: Related ingredient labels. + /// - reason: The reason for performing the action. public init( action: String, digitalSourceType: String? = nil, - softwareAgent: String? = ClaimGeneratorInfo.appName, - parameters: [String: String]? = nil + softwareAgent: AnyCodable? = nil, + parameters: [String: AnyCodable]? = nil, + when: String? = nil, + changes: [RegionOfInterest]? = nil, + related: [String]? = nil, + reason: String? = nil ) { self.action = action self.digitalSourceType = digitalSourceType self.softwareAgent = softwareAgent self.parameters = parameters + self.when = when + self.changes = changes + self.related = related + self.reason = reason } + /// Creates an action with a string softwareAgent (v1 format). + /// + /// - Parameters: + /// - action: The action name. Most probably a ``PredefinedAction``. + /// - digitalSourceType: A URL identifying an IPTC term. Most probably a ``DigitalSourceType``. + /// - softwareAgent: The software or hardware used to perform the action. Defaults to the app name. + /// - parameters: Additional information describing the action. + /// - when: The timestamp when the action was performed (ISO 8601 format). + /// - changes: Regions of interest describing what changed. + /// - related: Related ingredient labels. + /// - reason: The reason for performing the action. + public init( + action: String, + digitalSourceType: String? = nil, + softwareAgent: String?, + parameters: [String: AnyCodable]? = nil, + when: String? = nil, + changes: [RegionOfInterest]? = nil, + related: [String]? = nil, + reason: String? = nil + ) { + self.init( + action: action, + digitalSourceType: digitalSourceType, + softwareAgent: softwareAgent.map { AnyCodable($0) }, + parameters: parameters, + when: when, + changes: changes, + related: related, + reason: reason + ) + } + + /// Creates an action with a ``PredefinedAction`` and ``DigitalSourceType``. + /// /// - Parameters: /// - action: The action name as a ``PredefinedAction``. /// - digitalSourceType: A URL identifying an IPTC term as a ``DigitalSourceType``. /// - softwareAgent: The software or hardware used to perform the action. Defaults to the app name. /// - parameters: Additional information describing the action. + /// - when: The timestamp when the action was performed (ISO 8601 format). + /// - changes: Regions of interest describing what changed. + /// - related: Related ingredient labels. + /// - reason: The reason for performing the action. public init( action: PredefinedAction, digitalSourceType: DigitalSourceType, softwareAgent: String? = ClaimGeneratorInfo.appName, - parameters: [String: String]? = nil + parameters: [String: AnyCodable]? = nil, + when: String? = nil, + changes: [RegionOfInterest]? = nil, + related: [String]? = nil, + reason: String? = nil ) { self.init( action: action.rawValue, digitalSourceType: digitalSourceType.rawValue, softwareAgent: softwareAgent, - parameters: parameters) + parameters: parameters, + when: when, + changes: changes, + related: related, + reason: reason + ) + } + + /// Creates an action with a ``ClaimGeneratorInfo`` as v2 softwareAgent. + /// + /// - Parameters: + /// - action: The action name as a ``PredefinedAction``. + /// - digitalSourceType: A URL identifying an IPTC term as a ``DigitalSourceType``. + /// - softwareAgentInfo: The v2 ``ClaimGeneratorInfo`` for the software that performed the action. + /// - parameters: Additional information describing the action. + /// - when: The timestamp when the action was performed (ISO 8601 format). + /// - changes: Regions of interest describing what changed. + /// - related: Related ingredient labels. + /// - reason: The reason for performing the action. + public init( + action: PredefinedAction, + digitalSourceType: DigitalSourceType? = nil, + softwareAgentInfo: ClaimGeneratorInfo, + parameters: [String: AnyCodable]? = nil, + when: String? = nil, + changes: [RegionOfInterest]? = nil, + related: [String]? = nil, + reason: String? = nil + ) { + let agentData = try? JSONEncoder().encode(softwareAgentInfo) + let agentDict = agentData.flatMap { + try? JSONSerialization.jsonObject(with: $0) as? [String: Any] + } + self.init( + action: action.rawValue, + digitalSourceType: digitalSourceType?.rawValue, + softwareAgent: agentDict.map { AnyCodable($0) }, + parameters: parameters, + when: when, + changes: changes, + related: related, + reason: reason + ) + } + + // MARK: - Equatable + + public static func == (lhs: Action, rhs: Action) -> Bool { + lhs.action == rhs.action + && lhs.digitalSourceType == rhs.digitalSourceType + && lhs.softwareAgent == rhs.softwareAgent + && lhs.parameters == rhs.parameters + && lhs.when == rhs.when + && lhs.changes == rhs.changes + && lhs.related == rhs.related + && lhs.reason == rhs.reason } } diff --git a/Library/Sources/Manifest/AssertionDefinition.swift b/Library/Sources/Manifest/AssertionDefinition.swift index 6247783..7c65b1d 100644 --- a/Library/Sources/Manifest/AssertionDefinition.swift +++ b/Library/Sources/Manifest/AssertionDefinition.swift @@ -16,7 +16,20 @@ import Foundation /// Defines an assertion in a C2PA manifest. /// /// An assertion consists of a label identifying its type and associated data. -/// Labels can be standard C2PA labels or custom reverse-domain labels. +/// Labels can be standard C2PA labels, CAWG labels, or custom reverse-domain labels. +/// +/// ## Standard Assertions +/// +/// The most commonly used assertions are: +/// - ``actions(actions:)`` -- describes operations performed on the content +/// - ``creativeWork(data:)`` -- Schema.org CreativeWork metadata +/// - ``trainingMining(entries:)`` -- C2PA training/mining permissions +/// - ``cawgIdentity(data:)`` -- CAWG identity assertion (must be in gathered assertions) +/// +/// ## Custom Assertions +/// +/// Use ``custom(label:data:)`` for assertions not covered by the standard types. +/// Custom labels must use reverse-domain format (e.g., `com.example.myassertion`). /// /// - SeeAlso: [AssertionDefinition Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema#assertiondefinition) /// - SeeAlso: ``StandardAssertionLabel`` @@ -26,9 +39,34 @@ public enum AssertionDefinition: Codable, Equatable { case data } + // MARK: - Cases + + /// An actions assertion describing operations on the content. + /// /// - SeeAlso: [Actions Reference](https://opensource.contentauthenticity.org/docs/manifest/writing/assertions-actions#actions) case actions(actions: [Action]) + /// A Schema.org CreativeWork metadata assertion. + case creativeWork(data: [String: AnyCodable]) + + /// A C2PA training/mining permission assertion. + case trainingMining(entries: [TrainingMiningEntry]) + + /// A CAWG identity assertion. + case cawgIdentity(data: [String: AnyCodable]) + + /// A CAWG AI training and data mining assertion. + case cawgTrainingMining(entries: [CawgTrainingMiningEntry]) + + /// A custom assertion with a user-defined label. + /// + /// - Parameters: + /// - label: The assertion label in reverse-domain format. + /// - data: The assertion data. + case custom(label: String, data: AnyCodable) + + // MARK: - Standard C2PA Assertions (data-less) + case assertionMetadata case assetRef case assetType @@ -49,139 +87,269 @@ public enum AssertionDefinition: Codable, Equatable { case thumbnailIngredient case timeStamps + // MARK: - Base Label + + /// The base label string for this assertion. + public var baseLabel: String { + switch self { + case .actions: return StandardAssertionLabel.actions.rawValue + case .creativeWork: return StandardAssertionLabel.creativeWork.rawValue + case .trainingMining: return StandardAssertionLabel.trainingMining.rawValue + case .cawgIdentity: return StandardAssertionLabel.cawgIdentity.rawValue + case .cawgTrainingMining: return StandardAssertionLabel.cawgAITraining.rawValue + case .custom(let label, _): return label + case .assertionMetadata: return StandardAssertionLabel.assertionMetadata.rawValue + case .assetRef: return StandardAssertionLabel.assetRef.rawValue + case .assetType: return StandardAssertionLabel.assetType.rawValue + case .bmffBasedHash: return StandardAssertionLabel.bmffBasedHash.rawValue + case .certificateStatus: return StandardAssertionLabel.certificateStatus.rawValue + case .cloudData: return StandardAssertionLabel.cloudData.rawValue + case .collectionDataHash: return StandardAssertionLabel.collectionDataHash.rawValue + case .dataHash: return StandardAssertionLabel.dataHash.rawValue + case .depthmap: return StandardAssertionLabel.depthmap.rawValue + case .embeddedData: return StandardAssertionLabel.embeddedData.rawValue + case .fontInfo: return StandardAssertionLabel.fontInfo.rawValue + case .generalBoxHash: return StandardAssertionLabel.generalBoxHash.rawValue + case .ingredient: return StandardAssertionLabel.ingredient.rawValue + case .metadata: return StandardAssertionLabel.metadata.rawValue + case .multiAssetHash: return StandardAssertionLabel.multiAssetHash.rawValue + case .softBinding: return StandardAssertionLabel.softBinding.rawValue + case .thumbnailClaim: return StandardAssertionLabel.thumbnailClaim.rawValue + case .thumbnailIngredient: return StandardAssertionLabel.thumbnailIngredient.rawValue + case .timeStamps: return StandardAssertionLabel.timeStamps.rawValue + } + } + + // MARK: - Codable + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let label = try container.decode(StandardAssertionLabel.self, forKey: .label) + let labelString = try container.decode(String.self, forKey: .label) - switch label { - case .actions: - let actions = try container.decode([String: [Action]].self, forKey: .data) + guard let standardLabel = StandardAssertionLabel(rawValue: labelString) else { + // Custom assertion with unknown label + let data = try container.decode(AnyCodable.self, forKey: .data) + self = .custom(label: labelString, data: data) + return + } + switch standardLabel { + case .actions, .actionsV2: + let actions = try container.decode([String: [Action]].self, forKey: .data) self = .actions(actions: actions["actions"] ?? []) + case .creativeWork: + let data = try container.decode([String: AnyCodable].self, forKey: .data) + self = .creativeWork(data: data) + case .trainingMining: + let data = try container.decode([String: [TrainingMiningEntry]].self, forKey: .data) + self = .trainingMining(entries: data["entries"] ?? []) + case .cawgIdentity: + let data = try container.decode([String: AnyCodable].self, forKey: .data) + self = .cawgIdentity(data: data) + case .cawgAITraining: + let data = try container.decode([String: [CawgTrainingMiningEntry]].self, forKey: .data) + self = .cawgTrainingMining(entries: data["entries"] ?? []) + case .assertionMetadata: self = .assertionMetadata + case .assetRef: self = .assetRef + case .assetType: self = .assetType + case .bmffBasedHash: self = .bmffBasedHash + case .certificateStatus: self = .certificateStatus + case .cloudData: self = .cloudData + case .collectionDataHash: self = .collectionDataHash + case .dataHash: self = .dataHash + case .depthmap: self = .depthmap + case .embeddedData: self = .embeddedData + case .fontInfo: self = .fontInfo + case .generalBoxHash: self = .generalBoxHash + case .ingredient, .ingredientV3: self = .ingredient + case .metadata: self = .metadata + case .multiAssetHash: self = .multiAssetHash + case .softBinding: self = .softBinding + case .thumbnailClaim: self = .thumbnailClaim + case .thumbnailIngredient: self = .thumbnailIngredient + case .timeStamps: self = .timeStamps + } + } - case .assertionMetadata: - self = .assertionMetadata + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .actions(let actions): + try container.encode(StandardAssertionLabel.actions.rawValue, forKey: .label) + try container.encode(["actions": actions], forKey: .data) + case .creativeWork(let data): + try container.encode(StandardAssertionLabel.creativeWork.rawValue, forKey: .label) + try container.encode(data, forKey: .data) + case .trainingMining(let entries): + try container.encode(StandardAssertionLabel.trainingMining.rawValue, forKey: .label) + try container.encode(["entries": entries], forKey: .data) + case .cawgIdentity(let data): + try container.encode(StandardAssertionLabel.cawgIdentity.rawValue, forKey: .label) + try container.encode(data, forKey: .data) + case .cawgTrainingMining(let entries): + try container.encode(StandardAssertionLabel.cawgAITraining.rawValue, forKey: .label) + try container.encode(["entries": entries], forKey: .data) + case .custom(let label, let data): + try container.encode(label, forKey: .label) + try container.encode(data, forKey: .data) + case .assertionMetadata: + try container.encode(StandardAssertionLabel.assertionMetadata.rawValue, forKey: .label) case .assetRef: - self = .assetRef - + try container.encode(StandardAssertionLabel.assetRef.rawValue, forKey: .label) case .assetType: - self = .assetType - + try container.encode(StandardAssertionLabel.assetType.rawValue, forKey: .label) case .bmffBasedHash: - self = .bmffBasedHash - + try container.encode(StandardAssertionLabel.bmffBasedHash.rawValue, forKey: .label) case .certificateStatus: - self = .certificateStatus - + try container.encode(StandardAssertionLabel.certificateStatus.rawValue, forKey: .label) case .cloudData: - self = .cloudData - + try container.encode(StandardAssertionLabel.cloudData.rawValue, forKey: .label) case .collectionDataHash: - self = .collectionDataHash - + try container.encode(StandardAssertionLabel.collectionDataHash.rawValue, forKey: .label) case .dataHash: - self = .dataHash - + try container.encode(StandardAssertionLabel.dataHash.rawValue, forKey: .label) case .depthmap: - self = .depthmap - + try container.encode(StandardAssertionLabel.depthmap.rawValue, forKey: .label) case .embeddedData: - self = .embeddedData - + try container.encode(StandardAssertionLabel.embeddedData.rawValue, forKey: .label) case .fontInfo: - self = .fontInfo - + try container.encode(StandardAssertionLabel.fontInfo.rawValue, forKey: .label) case .generalBoxHash: - self = .generalBoxHash - + try container.encode(StandardAssertionLabel.generalBoxHash.rawValue, forKey: .label) case .ingredient: - self = .ingredient - + try container.encode(StandardAssertionLabel.ingredient.rawValue, forKey: .label) case .metadata: - self = .metadata - + try container.encode(StandardAssertionLabel.metadata.rawValue, forKey: .label) case .multiAssetHash: - self = .multiAssetHash - + try container.encode(StandardAssertionLabel.multiAssetHash.rawValue, forKey: .label) case .softBinding: - self = .softBinding - + try container.encode(StandardAssertionLabel.softBinding.rawValue, forKey: .label) case .thumbnailClaim: - self = .thumbnailClaim - + try container.encode(StandardAssertionLabel.thumbnailClaim.rawValue, forKey: .label) case .thumbnailIngredient: - self = .thumbnailIngredient - + try container.encode(StandardAssertionLabel.thumbnailIngredient.rawValue, forKey: .label) case .timeStamps: - self = .timeStamps + try container.encode(StandardAssertionLabel.timeStamps.rawValue, forKey: .label) } } +} - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case .actions(let actions): - try container.encode(StandardAssertionLabel.actions, forKey: .label) - try container.encode(["actions": actions], forKey: .data) - - case .assertionMetadata: - try container.encode(StandardAssertionLabel.assertionMetadata, forKey: .label) - - case .assetRef: - try container.encode(StandardAssertionLabel.assetRef, forKey: .label) - - case .assetType: - try container.encode(StandardAssertionLabel.assetType, forKey: .label) +// MARK: - Supporting Types - case .bmffBasedHash: - try container.encode(StandardAssertionLabel.bmffBasedHash, forKey: .label) +/// An entry in a C2PA training/mining assertion. +public struct TrainingMiningEntry: Codable, Equatable, Sendable { + /// The permitted use (e.g., "notAllowed", "constrained", "allowed"). + public let use: String - case .certificateStatus: - try container.encode(StandardAssertionLabel.certificateStatus, forKey: .label) + /// Additional constraint information when use is "constrained". + public let constraintInfo: String? - case .cloudData: - try container.encode(StandardAssertionLabel.cloudData, forKey: .label) - - case .collectionDataHash: - try container.encode(StandardAssertionLabel.collectionDataHash, forKey: .label) + public enum CodingKeys: String, CodingKey { + case use + case constraintInfo = "constraint_info" + } - case .dataHash: - try container.encode(StandardAssertionLabel.dataHash, forKey: .label) + public init(use: String, constraintInfo: String? = nil) { + self.use = use + self.constraintInfo = constraintInfo + } +} - case .depthmap: - try container.encode(StandardAssertionLabel.depthmap, forKey: .label) +/// An entry in a CAWG AI training and data mining assertion. +public struct CawgTrainingMiningEntry: Codable, Equatable, Sendable { + /// The permitted use. + public let use: String - case .embeddedData: - try container.encode(StandardAssertionLabel.embeddedData, forKey: .label) + /// Additional constraint information. + public let constraintInfo: String? - case .fontInfo: - try container.encode(StandardAssertionLabel.fontInfo, forKey: .label) + /// The AI model learning type. + public let aiModelLearningType: String? - case .generalBoxHash: - try container.encode(StandardAssertionLabel.generalBoxHash, forKey: .label) + /// The AI mining type. + public let aiMiningType: String? - case .ingredient: - try container.encode(StandardAssertionLabel.ingredient, forKey: .label) + public enum CodingKeys: String, CodingKey { + case use + case constraintInfo = "constraint_info" + case aiModelLearningType = "ai_model_learning_type" + case aiMiningType = "ai_mining_type" + } - case .metadata: - try container.encode(StandardAssertionLabel.metadata, forKey: .label) + public init( + use: String, + constraintInfo: String? = nil, + aiModelLearningType: String? = nil, + aiMiningType: String? = nil + ) { + self.use = use + self.constraintInfo = constraintInfo + self.aiModelLearningType = aiModelLearningType + self.aiMiningType = aiMiningType + } +} - case .multiAssetHash: - try container.encode(StandardAssertionLabel.multiAssetHash, forKey: .label) +/// A type-erased Codable value for dynamic JSON data. +/// +/// Used in assertion types that carry unstructured JSON payloads +/// (creative work metadata, CAWG identity data, custom assertions). +public struct AnyCodable: Codable, Equatable { + public let value: Any - case .softBinding: - try container.encode(StandardAssertionLabel.softBinding, forKey: .label) + public init(_ value: Any) { + self.value = value + } - case .thumbnailClaim: - try container.encode(StandardAssertionLabel.thumbnailClaim, forKey: .label) + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self.value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + self.value = bool + } else if let int = try? container.decode(Int.self) { + self.value = int + } else if let double = try? container.decode(Double.self) { + self.value = double + } else if let string = try? container.decode(String.self) { + self.value = string + } else if let array = try? container.decode([AnyCodable].self) { + self.value = array.map { $0.value } + } else if let dict = try? container.decode([String: AnyCodable].self) { + self.value = dict.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported value type") + } + } - case .thumbnailIngredient: - try container.encode(StandardAssertionLabel.thumbnailIngredient, forKey: .label) + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case is NSNull: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + default: + throw EncodingError.invalidValue(value, .init(codingPath: encoder.codingPath, debugDescription: "Unsupported value type")) + } + } - case .timeStamps: - try container.encode(StandardAssertionLabel.timeStamps, forKey: .label) + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + // Compare serialized JSON for equality + guard let lhsData = try? JSONEncoder().encode(lhs), + let rhsData = try? JSONEncoder().encode(rhs) else { + return false } + return lhsData == rhsData } } diff --git a/Library/Sources/Manifest/AssetType.swift b/Library/Sources/Manifest/AssetType.swift index 2533790..15b79df 100644 --- a/Library/Sources/Manifest/AssetType.swift +++ b/Library/Sources/Manifest/AssetType.swift @@ -14,7 +14,7 @@ import Foundation /// - SeeAlso: [AssetType Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema#assettype) -public struct AssetType: Codable, Equatable { +public struct AssetType: Codable, Equatable, Sendable { public var type: String diff --git a/Library/Sources/Manifest/Coordinate.swift b/Library/Sources/Manifest/Coordinate.swift index aa28ac8..1f0dc6a 100644 --- a/Library/Sources/Manifest/Coordinate.swift +++ b/Library/Sources/Manifest/Coordinate.swift @@ -15,7 +15,7 @@ import Foundation /// An x, y coordinate used for specifying vertices in polygons. /// - SeeAlso: [Coordinate Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#coordinate) -public struct Coordinate: Codable, Equatable { +public struct Coordinate: Codable, Equatable, Sendable { /// The coordinate along the x-axis. public var x: Double diff --git a/Library/Sources/Manifest/DigitalSourceType.swift b/Library/Sources/Manifest/DigitalSourceType.swift index 8c72044..417c056 100644 --- a/Library/Sources/Manifest/DigitalSourceType.swift +++ b/Library/Sources/Manifest/DigitalSourceType.swift @@ -15,7 +15,7 @@ import Foundation /// The value of digitalSourceType is one of the URLs specified by the International Press Telecommunications Council (IPTC) NewsCodes Digital Source Type scheme of the form http://cv.iptc.org/newscodes/digitalsourcetype/ /// - SeeAlso: [DigitalSourceType Reference](https://opensource.contentauthenticity.org/docs/manifest/writing/assertions-actions#digital-source-type) -public enum DigitalSourceType: String, Codable { +public enum DigitalSourceType: String, Codable, Sendable { /// Minor augmentation or correction by algorithm. case algorithmicallyEnhanced = "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced" diff --git a/Library/Sources/Manifest/Frame.swift b/Library/Sources/Manifest/Frame.swift index 95aa5c5..abb8a48 100644 --- a/Library/Sources/Manifest/Frame.swift +++ b/Library/Sources/Manifest/Frame.swift @@ -15,7 +15,7 @@ import Foundation /// A frame range representing starting and ending frames or pages. If both ``start`` and ``end`` are missing, the frame will span the entire asset. /// - SeeAlso: [Frame Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#frame) -public struct Frame: Codable, Equatable { +public struct Frame: Codable, Equatable, Sendable { /// The end of the frame inclusive or the end of the asset if not present. public var end: Int32? diff --git a/Library/Sources/Manifest/ImageRegionType.swift b/Library/Sources/Manifest/ImageRegionType.swift index 2025ed2..90f0d3b 100644 --- a/Library/Sources/Manifest/ImageRegionType.swift +++ b/Library/Sources/Manifest/ImageRegionType.swift @@ -15,7 +15,7 @@ import Foundation /// Image Region Type controlled vocabulary /// - SeeAlso: [imageregiontype](https://cv.iptc.org/newscodes/imageregiontype/) -public enum ImageRegionType: String, Codable { +public enum ImageRegionType: String, Codable, Sendable { /// A living organism different from humans or flora case animal = "http://cv.iptc.org/newscodes/imageregiontype/animal" @@ -42,6 +42,15 @@ public enum ImageRegionType: String, Codable { /// A human being case human = "http://cv.iptc.org/newscodes/imageregiontype/human" + /// A face within the region + case face = "http://cv.iptc.org/newscodes/imageregiontype/face" + + /// A headshot of a person + case headshot = "http://cv.iptc.org/newscodes/imageregiontype/headshot" + + /// A body part + case bodyPart = "http://cv.iptc.org/newscodes/imageregiontype/bodyPart" + /// A thing that was produced and can be handed over case product = "http://cv.iptc.org/newscodes/imageregiontype/product" @@ -66,4 +75,19 @@ public enum ImageRegionType: String, Codable { /// A significant accumulation of water /// Including a waterfall, a geyser and other phenomena of water case bodyOfWater = "http://cv.iptc.org/newscodes/imageregiontype/bodyOfWater" + + /// A thing or entity + case object = "http://cv.iptc.org/newscodes/imageregiontype/object" + + /// An occurrence or happening + case event = "http://cv.iptc.org/newscodes/imageregiontype/event" + + /// A recognizable sign, symbol, or design identifying a product or organization + case logo = "http://cv.iptc.org/newscodes/imageregiontype/logo" + + /// A visible code such as a QR code or barcode + case visibleCode = "http://cv.iptc.org/newscodes/imageregiontype/visibleCode" + + /// A geographical feature or landmark + case geoFeature = "http://cv.iptc.org/newscodes/imageregiontype/geoFeature" } diff --git a/Library/Sources/Manifest/Ingredient.swift b/Library/Sources/Manifest/Ingredient.swift index 3d9a7d7..772dd82 100644 --- a/Library/Sources/Manifest/Ingredient.swift +++ b/Library/Sources/Manifest/Ingredient.swift @@ -153,4 +153,45 @@ public struct Ingredient: Codable, Equatable { self.validationResults = validationResults self.validationStatus = validationStatus } + + // MARK: - Factory Methods + + /// Creates a parent ingredient. + /// + /// A parent ingredient is the primary source asset that was modified + /// to create the current asset. + /// + /// - Parameters: + /// - title: A human-readable title. + /// - format: Optional MIME type of the ingredient. + /// - Returns: An ingredient configured as a parent. + public static func parent(title: String, format: String? = nil) -> Ingredient { + Ingredient(format: format, relationship: .parentOf, title: title) + } + + /// Creates a component ingredient. + /// + /// A component ingredient is a secondary source asset incorporated + /// into the current asset (e.g., a watermark or overlay). + /// + /// - Parameters: + /// - title: A human-readable title. + /// - format: Optional MIME type of the ingredient. + /// - Returns: An ingredient configured as a component. + public static func component(title: String, format: String? = nil) -> Ingredient { + Ingredient(format: format, relationship: .componentOf, title: title) + } + + /// Creates an inputTo ingredient. + /// + /// An inputTo ingredient was used as input to a process that + /// generated the current asset (e.g., training data for AI). + /// + /// - Parameters: + /// - title: A human-readable title. + /// - format: Optional MIME type of the ingredient. + /// - Returns: An ingredient configured as inputTo. + public static func inputTo(title: String, format: String? = nil) -> Ingredient { + Ingredient(format: format, relationship: .inputTo, title: title) + } } diff --git a/Library/Sources/Manifest/IngredientDeltaValidationResult.swift b/Library/Sources/Manifest/IngredientDeltaValidationResult.swift index 65f5d81..218e072 100644 --- a/Library/Sources/Manifest/IngredientDeltaValidationResult.swift +++ b/Library/Sources/Manifest/IngredientDeltaValidationResult.swift @@ -15,7 +15,7 @@ import Foundation /// Represents any changes or deltas between the current and previous validation results for an ingredient’s manifest. /// - SeeAlso: [IngredientDeltaValidationResult Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#ingredientdeltavalidationresult) -public struct IngredientDeltaValidationResult: Codable, Equatable { +public struct IngredientDeltaValidationResult: Codable, Equatable, Sendable { public enum CodingKeys: String, CodingKey { case ingredientAssertionUri = "ingredientAssertionURI" diff --git a/Library/Sources/Manifest/Item.swift b/Library/Sources/Manifest/Item.swift index 8a2c77b..f6e3676 100644 --- a/Library/Sources/Manifest/Item.swift +++ b/Library/Sources/Manifest/Item.swift @@ -15,7 +15,7 @@ import Foundation /// Description of the boundaries of an identified range. /// - SeeAlso: [Item Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#item) -public struct Item: Codable, Equatable { +public struct Item: Codable, Equatable, Sendable { /// The container-specific term used to identify items, such as “track_id” for MP4 or “item_ID” for HEIF. public var identifier: String diff --git a/Library/Sources/Manifest/ManifestDefinition.swift b/Library/Sources/Manifest/ManifestDefinition.swift index 48c255f..12b176d 100644 --- a/Library/Sources/Manifest/ManifestDefinition.swift +++ b/Library/Sources/Manifest/ManifestDefinition.swift @@ -15,6 +15,23 @@ import Foundation /// A definition for creating a C2PA manifest. /// +/// `ManifestDefinition` describes the structure and content of a C2PA manifest, +/// including assertions about the content, its provenance, and ingredients used. +/// +/// Whether an assertion is treated as "created" or "gathered" is determined by the +/// `created_assertions` setting in ``C2PASettings``, not by placement in a separate list. +/// +/// ## Example +/// +/// ```swift +/// let manifest = ManifestDefinition.created( +/// title: "photo.jpg", +/// claimGeneratorInfo: ClaimGeneratorInfo(name: "MyApp", version: "1.0"), +/// digitalSourceType: .digitalCapture +/// ) +/// let json = try manifest.toJSON() +/// ``` +/// /// - SeeAlso: [Manifest Definition Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema) public struct ManifestDefinition: Codable, CustomStringConvertible, Equatable { public enum CodingKeys: String, CodingKey { @@ -32,13 +49,15 @@ public struct ManifestDefinition: Codable, CustomStringConvertible, Equatable { case vendor } + // MARK: - Properties + /// A list of assertions. public var assertions: [AssertionDefinition] /// Claim Generator Info is always required with at least one entry. public var claimGeneratorInfo: [ClaimGeneratorInfo] - /// The version of the claim. Defaults to 1. + /// The version of the claim. Defaults to 2 for C2PA 2.x spec compliance. public var claimVersion: UInt8 /// The format of the source file as a MIME type. @@ -50,41 +69,45 @@ public struct ManifestDefinition: Codable, CustomStringConvertible, Equatable { /// Instance ID from xmpMM:InstanceID in XMP metadata. public var instanceId: String? - /// Allows you to pre-define the manifest label, which must be unique. Not intended for general use. If not set, it will be assigned automatically. + /// Pre-defined manifest label. Must be unique. Not intended for general use. public var label: String? - /// Optional manifest metadata. This will be deprecated in the future; not recommended to use. + /// Optional manifest metadata. @available(*, deprecated, message: "This will be deprecated in the future; not recommended to use.") public var metadata: [Metadata]? - /// A list of redactions - URIs to redacted assertions. + /// A list of redactions -- URIs to redacted assertions. public var redactions: [String]? - /// An optional ``ResourceRef`` to a thumbnail image that represents the asset that was signed. Must be available when the manifest is signed. + /// An optional ``ResourceRef`` to a thumbnail image. public var thumbnail: ResourceRef? /// A human-readable title, generally source filename. public var title: String - /// Optional prefix added to the generated Manifest Label This is typically a reverse domain name. + /// Optional prefix added to the generated manifest label (typically a reverse domain name). public var vendor: String? + // MARK: - Initialization + + /// Creates a manifest definition. + /// /// - Parameters: /// - assertions: A list of assertions. - /// - claimGeneratorInfo: Claim Generator Info is always required with at least one entry. - /// - claimVersion: The version of the claim. Defaults to 1. - /// - format: The format of the source file as a MIME type. + /// - claimGeneratorInfo: Required claim generator info (at least one entry). + /// - claimVersion: The claim version. Defaults to 2. + /// - format: The MIME type of the source file. /// - ingredients: A list of ingredients. - /// - instanceId: Instance ID from xmpMM:InstanceID in XMP metadata. - /// - label: Allows you to pre-define the manifest label, which must be unique. Not intended for general use. If not set, it will be assigned automatically. - /// - redactions: A list of redactions - URIs to redacted assertions. - /// - thumbnail: An optional ``ResourceRef`` to a thumbnail image that represents the asset that was signed. Must be available when the manifest is signed. - /// - title: A human-readable title, generally source filename. - /// - vendor: Optional prefix added to the generated Manifest Label. This is typically a reverse domain name. + /// - instanceId: Instance ID from XMP metadata. + /// - label: Pre-defined manifest label. + /// - redactions: URIs to redacted assertions. + /// - thumbnail: A thumbnail image reference. + /// - title: A human-readable title. + /// - vendor: Prefix for the generated manifest label. public init( assertions: [AssertionDefinition] = [], claimGeneratorInfo: [ClaimGeneratorInfo], - claimVersion: UInt8 = 1, + claimVersion: UInt8 = 2, format: String = "application/octet-stream", ingredients: [Ingredient] = [], instanceId: String? = nil, @@ -107,11 +130,93 @@ public struct ManifestDefinition: Codable, CustomStringConvertible, Equatable { self.vendor = vendor } + // MARK: - Factory Methods + + /// Creates a manifest for a newly created asset. + /// + /// - Parameters: + /// - title: A human-readable title. + /// - claimGeneratorInfo: The claim generator info. + /// - digitalSourceType: The type of digital source. + /// - Returns: A manifest definition configured for creation. + public static func created( + title: String, + claimGeneratorInfo: ClaimGeneratorInfo, + digitalSourceType: DigitalSourceType + ) -> ManifestDefinition { + ManifestDefinition( + assertions: [ + .actions(actions: [ + Action( + action: .created, + digitalSourceType: digitalSourceType + ) + ]) + ], + claimGeneratorInfo: [claimGeneratorInfo], + title: title + ) + } + + /// Creates a manifest for an edited asset. + /// + /// - Parameters: + /// - title: A human-readable title. + /// - claimGeneratorInfo: The claim generator info. + /// - parentIngredient: The parent ingredient being edited. + /// - editActions: The editing actions performed. + /// - Returns: A manifest definition configured for editing. + public static func edited( + title: String, + claimGeneratorInfo: ClaimGeneratorInfo, + parentIngredient: Ingredient, + editActions: [Action] + ) -> ManifestDefinition { + ManifestDefinition( + assertions: [.actions(actions: editActions)], + claimGeneratorInfo: [claimGeneratorInfo], + ingredients: [parentIngredient], + title: title + ) + } + + // MARK: - Convenience + + /// Returns the unique base labels of the assertions. + public func assertionLabels() -> [String] { + Array(Set(assertions.map { $0.baseLabel })) + } + + /// Encodes this manifest to a JSON string. + /// + /// - Returns: A JSON string representation. + /// - Throws: `EncodingError` if encoding fails. + public func toJSON() throws -> String { + try C2PAJson.encode(self) + } + + /// Encodes this manifest to a pretty-printed JSON string. + /// + /// - Returns: A formatted JSON string representation. + /// - Throws: `EncodingError` if encoding fails. + public func toPrettyJSON() throws -> String { + try C2PAJson.encodePretty(self) + } + + /// Decodes a manifest from a JSON string. + /// + /// - Parameter json: A JSON string to decode. + /// - Returns: The decoded manifest definition. + /// - Throws: `DecodingError` if decoding fails. + public static func fromJSON(_ json: String) throws -> ManifestDefinition { + try C2PAJson.decode(ManifestDefinition.self, from: json) + } + // MARK: - CustomStringConvertible public var description: String { do { - return try C2PAJson.encode(self) + return try toJSON() } catch { return "" } diff --git a/Library/Sources/Manifest/ManifestValidationResult.swift b/Library/Sources/Manifest/ManifestValidationResult.swift new file mode 100644 index 0000000..4e358ab --- /dev/null +++ b/Library/Sources/Manifest/ManifestValidationResult.swift @@ -0,0 +1,63 @@ +// 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. +// +// ManifestValidationResult.swift +// + +import Foundation + +/// The result of validating a manifest definition. +/// +/// `ManifestValidationResult` collects errors and warnings encountered during validation. +/// A result with no errors is considered valid. +/// +/// ## Example +/// +/// ```swift +/// let result = ManifestValidator.validate(manifest) +/// if result.isValid { +/// // Manifest passes validation +/// } else { +/// for error in result.errors { +/// print("Error: \(error)") +/// } +/// } +/// ``` +/// +/// - SeeAlso: ``ManifestValidator`` +public struct ManifestValidationResult: Sendable { + /// Validation errors that must be resolved. + public let errors: [String] + + /// Validation warnings that indicate potential issues. + public let warnings: [String] + + /// Whether the result contains any errors. + public var hasErrors: Bool { !errors.isEmpty } + + /// Whether the result contains any warnings. + public var hasWarnings: Bool { !warnings.isEmpty } + + /// Whether the validation passed (no errors). + public var isValid: Bool { !hasErrors } + + /// Creates a validation result. + /// + /// - Parameters: + /// - errors: Validation errors. + /// - warnings: Validation warnings. + public init(errors: [String] = [], warnings: [String] = []) { + self.errors = errors + self.warnings = warnings + } + + /// A valid result with no errors or warnings. + public static let valid = ManifestValidationResult() +} diff --git a/Library/Sources/Manifest/ManifestValidator.swift b/Library/Sources/Manifest/ManifestValidator.swift new file mode 100644 index 0000000..29906e7 --- /dev/null +++ b/Library/Sources/Manifest/ManifestValidator.swift @@ -0,0 +1,163 @@ +// 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. +// +// ManifestValidator.swift +// + +import Foundation + +/// Validates C2PA manifest definitions for spec compliance. +/// +/// `ManifestValidator` checks manifests against the C2PA 2.3 specification +/// and CAWG requirements, identifying errors that would prevent signing and +/// warnings about deprecated or suboptimal configurations. +/// +/// ## Example +/// +/// ```swift +/// let result = ManifestValidator.validate(manifest) +/// if result.isValid { +/// let builder = try Builder(manifestJSON: manifest.toJSON()) +/// } else { +/// for error in result.errors { +/// print("Validation error: \(error)") +/// } +/// } +/// ``` +/// +/// - SeeAlso: ``ManifestValidationResult``, ``ManifestDefinition`` +public enum ManifestValidator { + /// The recommended claim version for new manifests. + public static let recommendedClaimVersion: UInt8 = 2 + + /// Assertion labels that are deprecated in the C2PA 2.3 specification. + /// + /// These labels should be replaced with their modern equivalents: + /// - `stds.exif` -> use EXIF data in actions metadata + /// - `stds.iptc.photo-metadata` -> use IPTC data in actions metadata + /// - `c2pa.actions` -> use `c2pa.actions.v2` + public static let deprecatedAssertionLabels: [String: String] = [ + "stds.exif": "Use EXIF data in action metadata instead", + "stds.iptc.photo-metadata": "Use IPTC data in action metadata instead", + "c2pa.actions": "Use c2pa.actions.v2 for new manifests" + ] + + /// Default labels for created assertions. + public static let defaultCreatedAssertionLabels: [String] = [ + "c2pa.actions", + "c2pa.actions.v2", + "c2pa.thumbnail.claim", + "c2pa.thumbnail.ingredient", + "c2pa.ingredient", + "c2pa.ingredient.v3" + ] + + // MARK: - Validation + + /// Validates a manifest definition for C2PA spec compliance. + /// + /// - Parameter manifest: The manifest to validate. + /// - Returns: A ``ManifestValidationResult`` with any errors and warnings. + public static func validate(_ manifest: ManifestDefinition) -> ManifestValidationResult { + var errors: [String] = [] + var warnings: [String] = [] + + validateBasicRequirements(manifest, errors: &errors, warnings: &warnings) + validateAssertions(manifest, errors: &errors, warnings: &warnings) + validateIngredients(manifest, warnings: &warnings) + + return ManifestValidationResult(errors: errors, warnings: warnings) + } + + /// Validates and logs warnings for a ManifestDefinition. + /// + /// - Parameter manifest: The manifest to validate. + /// - Returns: A ``ManifestValidationResult`` with any errors or warnings found. + @discardableResult + public static func validateAndLog(_ manifest: ManifestDefinition) -> ManifestValidationResult { + let result = validate(manifest) + for error in result.errors { + NSLog("[C2PA] Manifest validation error: %@", error) + } + for warning in result.warnings { + NSLog("[C2PA] Manifest validation warning: %@", warning) + } + return result + } + + /// Validates a manifest JSON string. + /// + /// - Parameter manifestJSON: The JSON string to validate. + /// - Returns: A ``ManifestValidationResult`` with any errors and warnings. + public static func validateJSON(_ manifestJSON: String) -> ManifestValidationResult { + do { + let manifest = try C2PAJson.decode(ManifestDefinition.self, from: manifestJSON) + return validate(manifest) + } catch { + return ManifestValidationResult(errors: ["Failed to parse manifest JSON: \(error.localizedDescription)"]) + } + } + + // MARK: - Private + + private static func validateBasicRequirements( + _ manifest: ManifestDefinition, + errors: inout [String], + warnings: inout [String] + ) { + // Title is required + if manifest.title.isEmpty { + errors.append("Manifest title is required") + } + + // Claim generator info is required + if manifest.claimGeneratorInfo.isEmpty { + errors.append("At least one claim_generator_info entry is required") + } + + // Claim version + if manifest.claimVersion < recommendedClaimVersion { + warnings.append("Claim version \(manifest.claimVersion) is outdated; recommended version is \(recommendedClaimVersion)") + } + } + + private static func validateAssertions( + _ manifest: ManifestDefinition, + errors: inout [String], + warnings: inout [String] + ) { + for assertion in manifest.assertions { + let label = assertion.baseLabel + if let replacement = deprecatedAssertionLabels[label] { + warnings.append("Deprecated assertion label '\(label)': \(replacement)") + } + // Validate custom assertion label format (must use namespaced format) + if case .custom(let customLabel, _) = assertion { + if !customLabel.contains(".") { + warnings.append( + "Custom assertion label '\(customLabel)' should use namespaced format " + + "(e.g., 'com.example.custom' or vendor prefix)" + ) + } + } + } + } + + private static func validateIngredients( + _ manifest: ManifestDefinition, + warnings: inout [String] + ) { + let parentIngredients = manifest.ingredients.filter { $0.relationship == .parentOf } + if parentIngredients.count > 1 { + warnings.append("Multiple parent ingredients found; only one parent ingredient is allowed") + } + } + +} diff --git a/Library/Sources/Manifest/PredefinedAction.swift b/Library/Sources/Manifest/PredefinedAction.swift index 4de0c7a..9be63a6 100644 --- a/Library/Sources/Manifest/PredefinedAction.swift +++ b/Library/Sources/Manifest/PredefinedAction.swift @@ -13,8 +13,8 @@ import Foundation -/// - SeeAlso: [C2PA Specification: Actions](https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_actions) -public enum PredefinedAction: String, Codable { +/// - SeeAlso: [C2PA Specification: Actions](https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_actions) +public enum PredefinedAction: String, Codable, Sendable { /// (visible) Textual content was inserted into the asset, such as on a text layer or as a caption. case addedText = "c2pa.addedText" @@ -59,6 +59,12 @@ public enum PredefinedAction: String, Codable { /// Changes to appearance with applied filters, styles, etc. case filtered = "c2pa.filtered" + /// Final production step where assets are prepared for distribution. + case mastered = "c2pa.mastered" // swiftlint:disable:this inclusive_language + + /// Multiple audio ingredients (stems, vocals, drums, etc.) are combined and transformed. + case mixed = "c2pa.mixed" + /// An existing asset was opened and is being set as the parentOf ingredient. case opened = "c2pa.opened" @@ -74,15 +80,21 @@ public enum PredefinedAction: String, Codable { /// One or more assertions were redacted case redacted = "c2pa.redacted" + /// Components from one or more ingredients were combined in a transformative way. + case remixed = "c2pa.remixed" + /// A componentOf ingredient was removed. case removed = "c2pa.removed" /// A conversion of one packaging or container format to another. Content is repackaged without transcoding. This action is considered as a non-editorial transformation of the parentOf ingredient. case repackaged = "c2pa.repackaged" - /// Changes to either content dimensions, its file size or both + /// Changes to either content dimensions, its file size or both. case resized = "c2pa.resized" + /// Dimensions were changed while maintaining aspect ratio. + case resizedProportional = "c2pa.resized.proportional" + /// A conversion of one encoding to another, including resolution scaling, bitrate adjustment and encoding format change. This action is considered as a non-editorial transformation of the parentOf ingredient. case transcoded = "c2pa.transcoded" @@ -97,4 +109,45 @@ public enum PredefinedAction: String, Codable { /// An invisible watermark was inserted into the digital content for the purpose of creating a soft binding. case watermarked = "c2pa.watermarked" + + /// An invisible watermark was inserted that is cryptographically bound to this manifest (soft binding). + case watermarkedBound = "c2pa.watermarked.bound" + + /// An invisible watermark was inserted that is NOT cryptographically bound to this manifest. + case watermarkedUnbound = "c2pa.watermarked.unbound" + + // MARK: - Font Content Specification Actions + + /// Characters or character sets were added to the font. + case fontCharactersAdded = "font.charactersAdded" + + /// Characters or character sets were deleted from the font. + case fontCharactersDeleted = "font.charactersDeleted" + + /// Characters were both added and deleted from the font. + case fontCharactersModified = "font.charactersModified" + + /// A font was instantiated from a variable font. + case fontCreatedFromVariableFont = "font.createdFromVariableFont" + + /// The font was edited (catch-all). + case fontEdited = "font.edited" + + /// Hinting was applied to the font. + case fontHinted = "font.hinted" + + /// A combination of antecedent fonts. + case fontMerged = "font.merged" + + /// An OpenType feature was added. + case fontOpenTypeFeatureAdded = "font.openTypeFeatureAdded" + + /// An OpenType feature was modified. + case fontOpenTypeFeatureModified = "font.openTypeFeatureModified" + + /// An OpenType feature was removed. + case fontOpenTypeFeatureRemoved = "font.openTypeFeatureRemoved" + + /// The font was stripped to a sub-group of characters. + case fontSubset = "font.subset" } diff --git a/Library/Sources/Manifest/RangeType.swift b/Library/Sources/Manifest/RangeType.swift index e8cab07..0bbeafd 100644 --- a/Library/Sources/Manifest/RangeType.swift +++ b/Library/Sources/Manifest/RangeType.swift @@ -15,7 +15,7 @@ import Foundation /// The type of range for the region of interest. /// - SeeAlso: [RangeType Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#rangetype) -public enum RangeType: String, Codable { +public enum RangeType: String, Codable, Sendable { /// A spatial range. See ``Shape`` for more details. case spatial diff --git a/Library/Sources/Manifest/RegionRange.swift b/Library/Sources/Manifest/RegionRange.swift index 58f8620..fc1c15d 100644 --- a/Library/Sources/Manifest/RegionRange.swift +++ b/Library/Sources/Manifest/RegionRange.swift @@ -15,7 +15,7 @@ import Foundation /// A spatial, temporal, frame, or textual range describing the region of interest. /// - SeeAlso: [Range Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#range) -public struct RegionRange: Codable, Equatable { +public struct RegionRange: Codable, Equatable, Sendable { /// A frame range. public var frame: Frame? diff --git a/Library/Sources/Manifest/Relationship.swift b/Library/Sources/Manifest/Relationship.swift index 34cec5e..282bd7c 100644 --- a/Library/Sources/Manifest/Relationship.swift +++ b/Library/Sources/Manifest/Relationship.swift @@ -14,7 +14,7 @@ import Foundation /// - SeeAlso: [Relationship Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema#relationship) -public enum Relationship: String, Codable { +public enum Relationship: String, Codable, Sendable { case parentOf case componentOf diff --git a/Library/Sources/Manifest/ReviewRating.swift b/Library/Sources/Manifest/ReviewRating.swift index 518b7f3..61fd625 100644 --- a/Library/Sources/Manifest/ReviewRating.swift +++ b/Library/Sources/Manifest/ReviewRating.swift @@ -17,7 +17,7 @@ import Foundation /// /// - SeeAlso: [_claim_review](https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_claim_review) /// - SeeAlso: [ReviewRating Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#reviewrating) -public struct ReviewRating: Codable, Equatable { +public struct ReviewRating: Codable, Equatable, Sendable { public var code: String? diff --git a/Library/Sources/Manifest/Role.swift b/Library/Sources/Manifest/Role.swift index a01bd87..3f0c64a 100644 --- a/Library/Sources/Manifest/Role.swift +++ b/Library/Sources/Manifest/Role.swift @@ -15,7 +15,7 @@ import Foundation /// A role describing the region. /// - SeeAlso: [Role Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#role) -public enum Role: String, Codable { +public enum Role: String, Codable, Sendable { /// Arbitrary area worth identifying. case areaOfInterest = "c2pa.areaOfInterest" diff --git a/Library/Sources/Manifest/Shape.swift b/Library/Sources/Manifest/Shape.swift index 2ac8f45..abf7e7e 100644 --- a/Library/Sources/Manifest/Shape.swift +++ b/Library/Sources/Manifest/Shape.swift @@ -15,7 +15,7 @@ import Foundation /// A spatial range representing rectangle, circle, or a polygon. /// - SeeAlso: [Shape Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#shape) -public struct Shape: Codable, Equatable { +public struct Shape: Codable, Equatable, Sendable { /// The type of shape. public var type: ShapeType diff --git a/Library/Sources/Manifest/ShapeType.swift b/Library/Sources/Manifest/ShapeType.swift index 3252a98..21f7d10 100644 --- a/Library/Sources/Manifest/ShapeType.swift +++ b/Library/Sources/Manifest/ShapeType.swift @@ -15,7 +15,7 @@ import Foundation /// The type of shape for the range. /// - SeeAlso: [ShapeType Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#shapetype) -public enum ShapeType: String, Codable { +public enum ShapeType: String, Codable, Sendable { case rectangle case circle diff --git a/Library/Sources/Manifest/StandardAssertionLabel.swift b/Library/Sources/Manifest/StandardAssertionLabel.swift index 113c7e8..e90e0e4 100644 --- a/Library/Sources/Manifest/StandardAssertionLabel.swift +++ b/Library/Sources/Manifest/StandardAssertionLabel.swift @@ -15,9 +15,10 @@ import Foundation /// The standard C2PA and CAWG assertion labels. /// -/// - SeeAlso: [C2PA Specification: Standard C2PA Assertion Summary](https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_standard_c2pa_assertion_summary) -public enum StandardAssertionLabel: String, Codable { +/// - SeeAlso: [C2PA Specification: Standard C2PA Assertion Summary](https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_standard_c2pa_assertion_summary) +public enum StandardAssertionLabel: String, Codable, Sendable { case actions = "c2pa.actions" + case actionsV2 = "c2pa.actions.v2" case assertionMetadata = "c2pa.assertion.metadata" case assetRef = "c2pa.asset-ref" case assetType = "c2pa.asset-type.v2" @@ -25,16 +26,21 @@ public enum StandardAssertionLabel: String, Codable { case certificateStatus = "c2pa.certificate-status" case cloudData = "c2pa.cloud-data" case collectionDataHash = "c2pa.hash.collection.data" + case creativeWork = "stds.schema-org.CreativeWork" case dataHash = "c2pa.hash.data" case depthmap = "c2pa.depthmap.GDepth" case embeddedData = "c2pa.embedded-data" case fontInfo = "font.info" case generalBoxHash = "c2pa.hash.boxes" case ingredient = "c2pa.ingredient" + case ingredientV3 = "c2pa.ingredient.v3" case metadata = "c2pa.metadata" case multiAssetHash = "c2pa.hash.multi-asset" case softBinding = "c2pa.soft-binding" case thumbnailClaim = "c2pa.thumbnail.claim" case thumbnailIngredient = "c2pa.thumbnail.ingredient" case timeStamps = "c2pa.time-stamp" + case trainingMining = "c2pa.training-mining" + case cawgIdentity = "cawg.identity" + case cawgAITraining = "cawg.ai_training_and_data_mining" } diff --git a/Library/Sources/Manifest/StatusCodes.swift b/Library/Sources/Manifest/StatusCodes.swift index d881dbc..cf610d8 100644 --- a/Library/Sources/Manifest/StatusCodes.swift +++ b/Library/Sources/Manifest/StatusCodes.swift @@ -15,7 +15,7 @@ import Foundation /// Contains a set of success, informational, and failure validation status codes. /// - SeeAlso: [StatusCodes Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#statuscodes) -public struct StatusCodes: Codable, Equatable { +public struct StatusCodes: Codable, Equatable, Sendable { public var failure: [ValidationStatus] = [] diff --git a/Library/Sources/Manifest/Text.swift b/Library/Sources/Manifest/Text.swift index dc79e2f..b9f44f5 100644 --- a/Library/Sources/Manifest/Text.swift +++ b/Library/Sources/Manifest/Text.swift @@ -15,7 +15,7 @@ import Foundation /// A textual range representing multiple (possibly discontinuous) ranges of text. /// - SeeAlso: [Text Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#text) -public struct Text: Codable, Equatable { +public struct Text: Codable, Equatable, Sendable { /// The ranges of text to select. public var selectors: [TextSelectorRange] diff --git a/Library/Sources/Manifest/TextSelector.swift b/Library/Sources/Manifest/TextSelector.swift index 2ed0c76..bc61f5d 100644 --- a/Library/Sources/Manifest/TextSelector.swift +++ b/Library/Sources/Manifest/TextSelector.swift @@ -15,7 +15,7 @@ import Foundation /// Selects a range of text via a fragment identifier. This is modeled after the W3C Web Annotation selector model. /// - SeeAlso: [TextSelector Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#textselector) -public struct TextSelector: Codable, Equatable { +public struct TextSelector: Codable, Equatable, Sendable { /// The end character offset or the end of the fragment if not present. public var end: Int32? diff --git a/Library/Sources/Manifest/TextSelectorRange.swift b/Library/Sources/Manifest/TextSelectorRange.swift index b12aec6..97f1961 100644 --- a/Library/Sources/Manifest/TextSelectorRange.swift +++ b/Library/Sources/Manifest/TextSelectorRange.swift @@ -15,7 +15,7 @@ import Foundation /// One or two TextSelectors identifiying the range to select. /// - SeeAlso: [TextSelectorRange Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#textselectorrange) -public struct TextSelectorRange: Codable, Equatable { +public struct TextSelectorRange: Codable, Equatable, Sendable { /// The end of the text range. public var end: TextSelector? diff --git a/Library/Sources/Manifest/Time.swift b/Library/Sources/Manifest/Time.swift index 1496908..30c16c7 100644 --- a/Library/Sources/Manifest/Time.swift +++ b/Library/Sources/Manifest/Time.swift @@ -15,7 +15,7 @@ import Foundation /// A temporal range representing a starting time to an ending time. /// - SeeAlso: [Time Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#time) -public struct Time: Codable, Equatable { +public struct Time: Codable, Equatable, Sendable { /// The end time or the end of the asset if not present. public var end: String? diff --git a/Library/Sources/Manifest/TimeType.swift b/Library/Sources/Manifest/TimeType.swift index 643cbee..3b9f30c 100644 --- a/Library/Sources/Manifest/TimeType.swift +++ b/Library/Sources/Manifest/TimeType.swift @@ -15,7 +15,7 @@ import Foundation /// The type of time. /// - SeeAlso: [TimeType Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#timetype) -public enum TimeType: String, Codable { +public enum TimeType: String, Codable, Sendable { /// Times are described using Normal Play Time (npt) as described in RFC 2326. case npt diff --git a/Library/Sources/Manifest/UnitType.swift b/Library/Sources/Manifest/UnitType.swift index e42a4c7..78c04c3 100644 --- a/Library/Sources/Manifest/UnitType.swift +++ b/Library/Sources/Manifest/UnitType.swift @@ -15,7 +15,7 @@ import Foundation /// The type of unit for the range. /// - SeeAlso: [UnitType Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#unittype) -public enum UnitType: String, Codable { +public enum UnitType: String, Codable, Sendable { /// Use pixels. case pixel diff --git a/Library/Sources/Manifest/ValidationResults.swift b/Library/Sources/Manifest/ValidationResults.swift index 3ca064a..2a67ff2 100644 --- a/Library/Sources/Manifest/ValidationResults.swift +++ b/Library/Sources/Manifest/ValidationResults.swift @@ -15,7 +15,7 @@ import Foundation /// A map of validation results for a manifest store. The map contains the validation results for the active manifest and any ingredient deltas. It is normal for there to be many /// - SeeAlso: [ValidationResults Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#validationresults) -public struct ValidationResults: Codable, Equatable { +public struct ValidationResults: Codable, Equatable, Sendable { public var activeManifest: StatusCodes? diff --git a/Library/Sources/Manifest/ValidationStatus.swift b/Library/Sources/Manifest/ValidationStatus.swift index 829e5b1..3af086f 100644 --- a/Library/Sources/Manifest/ValidationStatus.swift +++ b/Library/Sources/Manifest/ValidationStatus.swift @@ -15,7 +15,7 @@ import Foundation /// A ValidationStatus struct describes the validation status of a specific part of a manifest. /// - SeeAlso: [ValidationStatus Reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/manifest-definition-schema/#validationstatus) -public struct ValidationStatus: Codable, Equatable { +public struct ValidationStatus: Codable, Equatable, Sendable { public var code: ValidationStatusCode diff --git a/Library/Sources/Manifest/ValidationStatusCode.swift b/Library/Sources/Manifest/ValidationStatusCode.swift index 97fc479..0a553b6 100644 --- a/Library/Sources/Manifest/ValidationStatusCode.swift +++ b/Library/Sources/Manifest/ValidationStatusCode.swift @@ -13,111 +13,332 @@ import Foundation -/// - SeeAlso: [C2PA Specification: returning_validation_results](https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_returning_validation_results) -public enum ValidationStatusCode: String, Codable { +/// Validation status codes as defined in the C2PA 2.3 specification (Section 15). +/// +/// These codes indicate the result of various validation checks performed on manifests. +/// Codes are organized into three categories: success, informational, and failure. +/// +/// - SeeAlso: [C2PA Specification: returning_validation_results](https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_returning_validation_results) +public enum ValidationStatusCode: String, Codable, Sendable { - /// The claim signature referenced in the ingredient’s claim validated. - case claimSignatureValidated = "claimSignature.validated" + // MARK: - Success Codes - /// The signing credential is listed on the validator’s trust list. - case signingCredentialTrusted = "signingCredential.trusted" + /// A non-embedded (remote) assertion was accessible at the time of validation. + case assertionAccessible = "assertion.accessible" - /// The time-stamp credential is listed on the validator’s trust list. - case timeStampTrusted = "timeStamp.trusted" + /// The alternative content representation hash matches. + case assertionAltContentMatch = "assertion.alternativeContentRepresentation.match" - /// The hash of the the referenced assertion in the ingredient’s manifest matches the corresponding hash in the assertion’s hashed URI in the claim. - case assertionHashedUriMatch = "assertion.hashedURI.match" + /// Hash of a box-based asset matches the hash declared in the BMFF hash assertion. + case assertionBmffHashMatch = "assertion.bmffHash.match" + + /// The box hash matches the asset. + case assertionBoxesHashMatch = "assertion.boxesHash.match" + + /// The collection hash matches. + case assertionCollectionHashMatch = "assertion.collectionHash.match" /// Hash of a byte range of the asset matches the hash declared in the data hash assertion. case assertionDataHashMatch = "assertion.dataHash.match" - /// Hash of a box-based asset matches the hash declared in the BMFF hash assertion. - case assertionBmffHashMatch = "assertion.bmffHash.match" + /// The hash of the referenced assertion in the ingredient's manifest matches the corresponding hash in the assertion's hashed URI in the claim. + case assertionHashedUriMatch = "assertion.hashedURI.match" - /// A non-embedded (remote) assertion was accessible at the time of validation. - case assertionAccessible = "assertion.accessible" + /// The multi-asset hash matches. + case assertionMultiAssetHashMatch = "assertion.multiAssetHash.match" - /// The referenced claim in the ingredient’s manifest cannot be found. - case claimMissing = "claim.missing" + /// The claim signature is within its validity period. + case claimSignatureInsideValidity = "claimSignature.insideValidity" - /// More than one claim box is present in the manifest. - case claimMultiple = "claim.multiple" + /// The claim signature referenced in the ingredient's claim validated. + case claimSignatureValidated = "claimSignature.validated" - /// No hard bindings are present in the claim. - case claimHardBindingsMissing = "claim.hardBindings.missing" + /// The ingredient's claim signature has been validated. + case ingredientClaimSignatureValidated = "ingredient.claimSignature.validated" - /// The hash of the the referenced ingredient claim in the manifest does not match the corresponding hash in the ingredient’s hashed URI in the claim. - case ingredientHashedUriMismatch = "ingredient.hashedURI.mismatch" + /// The ingredient's manifest has been validated. + case ingredientManifestValidated = "ingredient.manifest.validated" - /// The claim signature referenced in the ingredient’s claim cannot be found in its manifest. - case claimSignatureMissing = "claimSignature.missing" + /// The signing credential's OCSP status is not revoked. + case signingCredentialOcspNotRevoked = "signingCredential.ocsp.notRevoked" - /// The claim signature referenced in the ingredient’s claim failed to validate. - case claimSignatureMismatch = "claimSignature.mismatch" + /// The signing credential is listed on the validator's trust list. + case signingCredentialTrusted = "signingCredential.trusted" - /// The manifest has more than one ingredient whose relationship is parentOf. - case manifestMultipleParents = "manifest.multipleParents" + /// The time-stamp credential is listed on the validator's trust list. + case timeStampTrusted = "timeStamp.trusted" - /// The manifest is an update manifest, but it contains hard binding or actions assertions. - case manifestUpdateInvalid = "manifest.update.invalid" + /// The time-stamp has been validated. + case timeStampValidated = "timeStamp.validated" - /// The manifest is an update manifest, but it contains either zero or multiple parentOf ingredients. - case manifestUpdateWrongParents = "manifest.update.wrongParents" + // MARK: - Informational Codes - /// The signing credential is not listed on the validator’s trust list. - case signingCredentialUntrusted = "signingCredential.untrusted" + /// The algorithm used is deprecated. + case algorithmDeprecated = "algorithm.deprecated" - /// The signing credential is not valid for signing. - case signingCredentialInvalid = "signingCredential.invalid" + /// The BMFF hash has additional exclusions present. + case assertionBmffHashAdditionalExclusions = "assertion.bmffHash.additionalExclusionsPresent" - /// The signing credential has been revoked by the issuer. - case signingCredentialRevoked = "signingCredential.revoked" + /// The box hash has additional exclusions present. + case assertionBoxesHashAdditionalExclusions = "assertion.boxesHash.additionalExclusionsPresent" - /// The signing credential has expired. - case signingCredentialExpired = "signingCredential.expired" + /// The data hash has additional exclusions present. + case assertionDataHashAdditionalExclusions = "assertion.dataHash.additionalExclusionsPresent" + + /// The ingredient has unknown provenance. + case ingredientUnknownProvenance = "ingredient.unknownProvenance" + + /// The OCSP responder for the signing credential is inaccessible. + case signingCredentialOcspInaccessible = "signingCredential.ocsp.inaccessible" + + /// OCSP checking was skipped for the signing credential. + case signingCredentialOcspSkipped = "signingCredential.ocsp.skipped" + + /// The OCSP status of the signing credential is unknown. + case signingCredentialOcspUnknown = "signingCredential.ocsp.unknown" + + /// The time of signing is within the credential validity period. + case timeOfSigningInsideValidity = "timeOfSigning.insideValidity" + + /// The time of signing is outside the credential validity period. + case timeOfSigningOutsideValidity = "timeOfSigning.outsideValidity" + + /// The time-stamp credential is invalid. + case timeStampCredentialInvalid = "timeStamp.credentialInvalid" + + /// The time-stamp is malformed. + case timeStampMalformed = "timeStamp.malformed" /// The time-stamp does not correspond to the contents of the claim. case timeStampMismatch = "timeStamp.mismatch" - /// The time-stamp credential is not listed on the validator’s trust list. + /// The signed time-stamp attribute in the signature falls outside the validity window of the signing certificate or the TSA's certificate. + case timeStampOutsideValidity = "timeStamp.outsideValidity" + + /// The time-stamp credential is not listed on the validator's trust list. case timeStampUntrusted = "timeStamp.untrusted" - /// The signed time-stamp attribute in the signature falls outside the validity window of the signing certificate or the TSA’s certificate. - case timeStampOutsideValidity = "timeStamp.outsideValidity" + // MARK: - Failure Codes - /// The hash of the the referenced assertion in the manifest does not match the corresponding hash in the assertion’s hashed URI in the claim. - case assertionHashedUriMismatch = "assertion.hashedURI.mismatch" + /// The value of an alg header, or other header that specifies an algorithm used to compute the value of another field, is unknown or unsupported. + case algorithmUnsupported = "algorithm.unsupported" - /// An assertion listed in the ingredient’s claim is missing from the ingredient’s manifest. - case assertionMissing = "assertion.missing" + /// The action assertion has an ingredient mismatch. + case assertionActionIngredientMismatch = "assertion.action.ingredientMismatch" - /// An assertion was found in the ingredient’s manifest that was not explicitly declared in the ingredient’s claim. - case assertionUndeclared = "assertion.undeclared" + /// The action assertion is malformed. + case assertionActionMalformed = "assertion.action.malformed" + + /// The action assertion has missing information. + case assertionActionMissing = "assertion.action.missing" + + /// An action assertion was redacted when the ingredient's claim was created. + case assertionActionRedacted = "assertion.action.redacted" + + /// The action assertion has a redaction mismatch. + case assertionActionRedactionMismatch = "assertion.action.redactionMismatch" + + /// The action assertion is missing a required soft binding. + case assertionActionSoftBindingMissing = "assertion.action.softBindingMissing" + + /// The alternative content representation is malformed. + case assertionAltContentMalformed = "assertion.alternativeContentRepresentation.malformed" + + /// The alternative content representation hash does not match. + case assertionAltContentHashMismatch = "assertion.alternativeContentRepresentation.hashMismatch" + + /// The alternative content representation is missing. + case assertionAltContentMissing = "assertion.alternativeContentRepresentation.missing" + + /// The BMFF hash is malformed. + case assertionBmffHashMalformed = "assertion.bmffHash.malformed" + + /// The hash of a box-based asset does not match the hash declared in the BMFF hash assertion. + case assertionBmffHashMismatch = "assertion.bmffHash.mismatch" + + /// The box hash is malformed. + case assertionBoxesHashMalformed = "assertion.boxesHash.malformed" + + /// The box hash does not match. + case assertionBoxesHashMismatch = "assertion.boxesHash.mismatch" + + /// An unknown box was encountered in the box hash. + case assertionBoxesHashUnknownBox = "assertion.boxesHash.unknownBox" + + /// The CBOR assertion data is invalid. + case assertionCborInvalid = "assertion.cbor.invalid" + + /// An update manifest contains a cloud data assertion referencing an actions assertion. + case assertionCloudDataActions = "assertion.cloud-data.actions" + + /// A hard binding assertion is in a cloud data assertion. + case assertionCloudDataHardBinding = "assertion.cloud-data.hardBinding" + + /// Cloud data assertion label does not match. + case assertionCloudDataLabelMismatch = "assertion.cloud-data.labelMismatch" + + /// Cloud data assertion is malformed. + case assertionCloudDataMalformed = "assertion.cloud-data.malformed" + + /// The collection hash has an incorrect file count. + case assertionCollectionHashIncorrectFileCount = "assertion.collectionHash.incorrectFileCount" + + /// The collection hash has an invalid URI. + case assertionCollectionHashInvalidUri = "assertion.collectionHash.invalidURI" + + /// The collection hash is malformed. + case assertionCollectionHashMalformed = "assertion.collectionHash.malformed" + + /// The collection hash does not match. + case assertionCollectionHashMismatch = "assertion.collectionHash.mismatch" + + /// The data hash is malformed. + case assertionDataHashMalformed = "assertion.dataHash.malformed" + + /// The hash of a byte range of the asset does not match the hash declared in the data hash assertion. + case assertionDataHashMismatch = "assertion.dataHash.mismatch" + + /// The external reference has incorrect actions. + case assertionExternalReferenceActions = "assertion.external-reference.actions" + + /// The external reference was created incorrectly. + case assertionExternalReferenceCreated = "assertion.external-reference.created" + + /// The external reference has incorrect hard binding. + case assertionExternalReferenceHardBinding = "assertion.external-reference.hardBinding" + + /// The external reference is malformed. + case assertionExternalReferenceMalformed = "assertion.external-reference.malformed" + + /// A hard binding assertion was redacted. + case assertionHardBindingRedacted = "assertion.hardBinding.redacted" + + /// The hash of the referenced assertion in the manifest does not match the corresponding hash in the assertion's hashed URI in the claim. + case assertionHashedUriMismatch = "assertion.hashedURI.mismatch" /// A non-embedded (remote) assertion was inaccessible at the time of validation. case assertionInaccessible = "assertion.inaccessible" - /// An assertion was declared as redacted in the ingredient’s claim but is still present in the ingredient’s manifest. + /// The ingredient assertion is malformed. + case assertionIngredientMalformed = "assertion.ingredient.malformed" + + /// The JSON assertion data is invalid. + case assertionJsonInvalid = "assertion.json.invalid" + + /// An assertion listed in the ingredient's claim is missing from the ingredient's manifest. + case assertionMissing = "assertion.missing" + + /// The multi-asset hash is malformed. + case assertionMultiAssetHashMalformed = "assertion.multiAssetHash.malformed" + + /// The multi-asset hash has a missing part. + case assertionMultiAssetHashMissingPart = "assertion.multiAssetHash.missingPart" + + /// The multi-asset hash does not match. + case assertionMultiAssetHashMismatch = "assertion.multiAssetHash.mismatch" + + /// Multiple hard bindings were found. + case assertionMultipleHardBindings = "assertion.multipleHardBindings" + + /// An assertion was declared as redacted in the ingredient's claim but is still present in the ingredient's manifest. case assertionNotRedacted = "assertion.notRedacted" + /// The assertion is outside the manifest. + case assertionOutsideManifest = "assertion.outsideManifest" + /// An assertion was declared as redacted by its own claim. case assertionSelfRedacted = "assertion.selfRedacted" - /// An action assertion was redacted when the ingredient’s claim was created. - case assertionActionRedacted = "assertion.action.redacted" + /// The assertion timestamp is malformed. + case assertionTimestampMalformed = "assertion.timestamp.malformed" - /// The hash of a byte range of the asset does not match the hash declared in the data hash assertion. - case assertionDataHashMismatch = "assertion.dataHash.mismatch" + /// An assertion was found in the ingredient's manifest that was not explicitly declared in the ingredient's claim. + case assertionUndeclared = "assertion.undeclared" - /// The hash of a box-based asset does not match the hash declared in the BMFF hash assertion. - case assertionBmffHashMismatch = "assertion.bmffHash.mismatch" + /// The claim CBOR data is invalid. + case claimCborInvalid = "claim.cbor.invalid" - /// A hard binding assertion is in a cloud data assertion. - case assertionCloudDataHardBinding = "assertion.cloud-data.hardBinding" + /// No hard bindings are present in the claim. + case claimHardBindingsMissing = "claim.hardBindings.missing" - /// An update manifest contains a cloud data assertion referencing an actions assertion. - case assertionCloudDataActions = "assertion.cloud-data.actions" + /// The claim is malformed. + case claimMalformed = "claim.malformed" - /// The value of an alg header, or other header that specifies an algorithm used to compute the value of another field, is unknown or unsupported. - case algorithmUnsupported = "algorithm.unsupported" + /// The referenced claim in the ingredient's manifest cannot be found. + case claimMissing = "claim.missing" + + /// More than one claim box is present in the manifest. + case claimMultiple = "claim.multiple" + + /// The claim signature referenced in the ingredient's claim failed to validate. + case claimSignatureMismatch = "claimSignature.mismatch" + + /// The claim signature referenced in the ingredient's claim cannot be found in its manifest. + case claimSignatureMissing = "claimSignature.missing" + + /// The claim signature is outside its validity period. + case claimSignatureOutsideValidity = "claimSignature.outsideValidity" + + /// A general error occurred. + case generalError = "general.error" + + /// A hashed URI reference does not match. + case hashedUriMismatch = "hashedURI.mismatch" + + /// A hashed URI reference is missing. + case hashedUriMissing = "hashedURI.missing" + + /// The ingredient's claim signature does not match. + case ingredientClaimSignatureMismatch = "ingredient.claimSignature.mismatch" + + /// The ingredient's claim signature is missing. + case ingredientClaimSignatureMissing = "ingredient.claimSignature.missing" + + /// The hash of the referenced ingredient claim in the manifest does not match the corresponding hash in the ingredient's hashed URI in the claim. + case ingredientHashedUriMismatch = "ingredient.hashedURI.mismatch" + + /// The ingredient's manifest does not match. + case ingredientManifestMismatch = "ingredient.manifest.mismatch" + + /// The ingredient's manifest is missing. + case ingredientManifestMissing = "ingredient.manifest.missing" + + /// A compressed manifest is invalid. + case manifestCompressedInvalid = "manifest.compressed.invalid" + + /// The manifest is inaccessible. + case manifestInaccessible = "manifest.inaccessible" + + /// The manifest is missing. + case manifestMissing = "manifest.missing" + + /// The manifest has more than one ingredient whose relationship is parentOf. + case manifestMultipleParents = "manifest.multipleParents" + + /// The manifest timestamp is invalid. + case manifestTimestampInvalid = "manifest.timestamp.invalid" + + /// The manifest timestamp has wrong parents. + case manifestTimestampWrongParents = "manifest.timestamp.wrongParents" + + /// The manifest is an update manifest, but it contains hard binding or actions assertions. + case manifestUpdateInvalid = "manifest.update.invalid" + + /// The manifest is an update manifest, but it contains either zero or multiple parentOf ingredients. + case manifestUpdateWrongParents = "manifest.update.wrongParents" + + /// The signing credential has expired. + case signingCredentialExpired = "signingCredential.expired" + + /// The signing credential is not valid for signing. + case signingCredentialInvalid = "signingCredential.invalid" + + /// The signing credential has been revoked (via OCSP). + case signingCredentialOcspRevoked = "signingCredential.ocsp.revoked" + + /// The signing credential has been revoked by the issuer. + case signingCredentialRevoked = "signingCredential.revoked" + + /// The signing credential is not listed on the validator's trust list. + case signingCredentialUntrusted = "signingCredential.untrusted" } diff --git a/Library/Sources/Reader.swift b/Library/Sources/Reader.swift index 4e7f1f9..ef637b1 100644 --- a/Library/Sources/Reader.swift +++ b/Library/Sources/Reader.swift @@ -31,7 +31,7 @@ import Foundation /// ### Reading Manifest Data /// - ``json()`` /// - ``detailedJSON()`` -/// - ``remoteURL()`` +/// - ``remote()`` /// - ``isEmbedded()`` /// /// ### Extracting Resources diff --git a/Library/Sources/SignerInfo.swift b/Library/Sources/SignerInfo.swift index 6cce9db..60eff26 100644 --- a/Library/Sources/SignerInfo.swift +++ b/Library/Sources/SignerInfo.swift @@ -32,7 +32,7 @@ import Foundation /// ``` /// /// - SeeAlso: ``Signer/init(info:)`` -public struct SignerInfo { +public struct SignerInfo: Sendable { /// The signing algorithm to use. public let algorithm: SigningAlgorithm diff --git a/Library/Tests/LibraryTestRunner.swift b/Library/Tests/LibraryTestRunner.swift index 81680f4..ef9cf20 100644 --- a/Library/Tests/LibraryTestRunner.swift +++ b/Library/Tests/LibraryTestRunner.swift @@ -422,6 +422,87 @@ final class ManifestTests: XCTestCase { let result = tests.testMassInit() XCTAssertTrue(result.passed, result.message) } + func testNewPredefinedActions() throws { + XCTAssertTrue(tests.testNewPredefinedActions().passed) + } + func testActionV2SoftwareAgent() throws { + XCTAssertTrue(tests.testActionV2SoftwareAgent().passed) + } + func testActionNewFields() throws { + XCTAssertTrue(tests.testActionNewFields().passed) + } + func testValidateAndLog() throws { + XCTAssertTrue(tests.testValidateAndLog().passed) + } + func testCustomAssertionLabelValidation() throws { + XCTAssertTrue(tests.testCustomAssertionLabelValidation().passed) + } + func testCreatedFactory() throws { + XCTAssertTrue(tests.testCreatedFactory().passed) + } + func testEditedFactory() throws { + XCTAssertTrue(tests.testEditedFactory().passed) + } + func testMixedAssertions() throws { + XCTAssertTrue(tests.testMixedAssertions().passed) + } + func testAssertionLabels() throws { + XCTAssertTrue(tests.testAssertionLabels().passed) + } + func testToJSON() throws { + XCTAssertTrue(tests.testToJSON().passed) + } + func testToPrettyJSON() throws { + XCTAssertTrue(tests.testToPrettyJSON().passed) + } + func testFromJSON() throws { + XCTAssertTrue(tests.testFromJSON().passed) + } + func testDescription() throws { + XCTAssertTrue(tests.testDescription().passed) + } + func testIngredientParentFactory() throws { + XCTAssertTrue(tests.testIngredientParentFactory().passed) + } + func testIngredientComponentFactory() throws { + XCTAssertTrue(tests.testIngredientComponentFactory().passed) + } + func testIngredientInputToFactory() throws { + XCTAssertTrue(tests.testIngredientInputToFactory().passed) + } + func testValidatorEmptyTitle() throws { + XCTAssertTrue(tests.testValidatorEmptyTitle().passed) + } + func testValidatorEmptyClaimGeneratorInfo() throws { + XCTAssertTrue(tests.testValidatorEmptyClaimGeneratorInfo().passed) + } + func testValidatorOldClaimVersion() throws { + XCTAssertTrue(tests.testValidatorOldClaimVersion().passed) + } + func testValidatorDeprecatedAssertionLabels() throws { + XCTAssertTrue(tests.testValidatorDeprecatedAssertionLabels().passed) + } + func testValidatorCawgAssertionAccepted() throws { + XCTAssertTrue(tests.testValidatorCawgAssertionAccepted().passed) + } + func testValidatorMultipleParents() throws { + XCTAssertTrue(tests.testValidatorMultipleParents().passed) + } + func testValidateJSON() throws { + XCTAssertTrue(tests.testValidateJSON().passed) + } + func testValidateJSONInvalid() throws { + XCTAssertTrue(tests.testValidateJSONInvalid().passed) + } + func testBuilderInitManifestValid() throws { + XCTAssertTrue(tests.testBuilderInitManifestValid().passed) + } + func testBuilderInitManifestInvalid() throws { + XCTAssertTrue(tests.testBuilderInitManifestInvalid().passed) + } + func testBuilderInitJSONInvalid() throws { + XCTAssertTrue(tests.testBuilderInitJSONInvalid().passed) + } } // MARK: - Certificate Manager Tests @@ -758,6 +839,30 @@ final class AssertionDefinitionTests: XCTestCase { let result = tests.testAssertionEquality() XCTAssertTrue(result.passed, result.message) } + func testCustomAssertionRoundTrip() throws { + XCTAssertTrue(tests.testCustomAssertionRoundTrip().passed) + } + func testTrainingMiningAssertion() throws { + XCTAssertTrue(tests.testTrainingMiningAssertion().passed) + } + func testCawgTrainingMiningAssertion() throws { + XCTAssertTrue(tests.testCawgTrainingMiningAssertion().passed) + } + func testCawgIdentityAssertion() throws { + XCTAssertTrue(tests.testCawgIdentityAssertion().passed) + } + func testCreativeWorkAssertion() throws { + XCTAssertTrue(tests.testCreativeWorkAssertion().passed) + } + func testAnyCodableTypes() throws { + XCTAssertTrue(tests.testAnyCodableTypes().passed) + } + func testAnyCodableEquality() throws { + XCTAssertTrue(tests.testAnyCodableEquality().passed) + } + func testActionsV2Decoding() throws { + XCTAssertTrue(tests.testActionsV2Decoding().passed) + } } // MARK: - Settings Definition Tests diff --git a/TestApp/Sources/TestRunner.swift b/TestApp/Sources/TestRunner.swift index 14ee295..a2410f7 100644 --- a/TestApp/Sources/TestRunner.swift +++ b/TestApp/Sources/TestRunner.swift @@ -53,12 +53,20 @@ public final class TestRunner: Sendable { return await WebServiceSignerTests().runAllTests() case .comprehensive: return await ComprehensiveTests().runAllTests() + case .manifest: + return await ManifestTests().runAllTests() + case .assertionDefinition: + return await AssertionDefinitionTests().runAllTests() + case .settingsDefinition: + return await SettingsDefinitionTests().runAllTests() + case .convenience: + return await ConvenienceTests().runAllTests() } } } // Available test suites -public enum TestSuite: String, CaseIterable { +public enum TestSuite: String, CaseIterable, Sendable { case stream = "Stream" case builder = "Builder" case reader = "Reader" @@ -70,6 +78,10 @@ public enum TestSuite: String, CaseIterable { case keychainSigner = "Keychain Signer" case webServiceSigner = "Web Service Signer" case comprehensive = "Comprehensive" + case manifest = "Manifest" + case assertionDefinition = "Assertion Definition" + case settingsDefinition = "Settings Definition" + case convenience = "Convenience" public var displayName: String { return rawValue + " Tests" diff --git a/TestShared/Sources/AssertionDefinitionTests.swift b/TestShared/Sources/AssertionDefinitionTests.swift index e577bcf..fb4d64d 100644 --- a/TestShared/Sources/AssertionDefinitionTests.swift +++ b/TestShared/Sources/AssertionDefinitionTests.swift @@ -366,6 +366,193 @@ public final class AssertionDefinitionTests: TestImplementation { testSteps.joined(separator: "\n")) } + // MARK: - Custom Assertion Tests + + public func testCustomAssertionRoundTrip() -> TestResult { + let assertion = AssertionDefinition.custom( + label: "com.example.test", + data: AnyCodable(["key": "value", "number": 42] as [String: Any]) + ) + do { + let data = try JSONEncoder().encode(assertion) + let decoded = try JSONDecoder().decode(AssertionDefinition.self, from: data) + if case .custom(let label, _) = decoded { + guard label == "com.example.test" else { + return .failure("Custom Round-Trip", "Label mismatch: \(label)") + } + } else { + return .failure("Custom Round-Trip", "Decoded as wrong type") + } + return .success("Custom Assertion", "[PASS] Custom assertion round-trip works") + } catch { + return .failure("Custom Assertion", "Error: \(error)") + } + } + + // MARK: - Training/Mining Assertion Tests + + public func testTrainingMiningAssertion() -> TestResult { + let entries = [ + TrainingMiningEntry(use: "notAllowed"), + TrainingMiningEntry(use: "constrained", constraintInfo: "License required") + ] + let assertion = AssertionDefinition.trainingMining(entries: entries) + do { + let data = try JSONEncoder().encode(assertion) + let decoded = try JSONDecoder().decode(AssertionDefinition.self, from: data) + if case .trainingMining(let decodedEntries) = decoded { + guard decodedEntries.count == 2 else { + return .failure("TrainingMining", "Expected 2 entries, got \(decodedEntries.count)") + } + guard decodedEntries[0].use == "notAllowed" else { + return .failure("TrainingMining", "First entry use mismatch") + } + guard decodedEntries[1].constraintInfo == "License required" else { + return .failure("TrainingMining", "Second entry constraintInfo mismatch") + } + } else { + return .failure("TrainingMining", "Decoded as wrong type") + } + return .success("TrainingMining", "[PASS] Training/mining assertion round-trip works") + } catch { + return .failure("TrainingMining", "Error: \(error)") + } + } + + public func testCawgTrainingMiningAssertion() -> TestResult { + let entries = [ + CawgTrainingMiningEntry( + use: "allowed", + constraintInfo: nil, + aiModelLearningType: "supervised", + aiMiningType: "text" + ) + ] + let assertion = AssertionDefinition.cawgTrainingMining(entries: entries) + do { + let data = try JSONEncoder().encode(assertion) + let decoded = try JSONDecoder().decode(AssertionDefinition.self, from: data) + if case .cawgTrainingMining(let decodedEntries) = decoded { + guard decodedEntries.first?.aiModelLearningType == "supervised" else { + return .failure("CawgTrainingMining", "aiModelLearningType mismatch") + } + } else { + return .failure("CawgTrainingMining", "Decoded as wrong type") + } + return .success("CawgTrainingMining", "[PASS] CAWG training/mining round-trip works") + } catch { + return .failure("CawgTrainingMining", "Error: \(error)") + } + } + + // MARK: - Other Typed Assertions + + public func testCawgIdentityAssertion() -> TestResult { + let assertion = AssertionDefinition.cawgIdentity(data: [ + "sig_type": AnyCodable("cawg.x509"), + "pad1": AnyCodable(0) + ]) + do { + let data = try JSONEncoder().encode(assertion) + let decoded = try JSONDecoder().decode(AssertionDefinition.self, from: data) + if case .cawgIdentity(let decodedData) = decoded { + guard decodedData["sig_type"] != nil else { + return .failure("CawgIdentity", "Missing sig_type in decoded data") + } + } else { + return .failure("CawgIdentity", "Decoded as wrong type") + } + return .success("CawgIdentity", "[PASS] CAWG identity assertion round-trip works") + } catch { + return .failure("CawgIdentity", "Error: \(error)") + } + } + + public func testCreativeWorkAssertion() -> TestResult { + let assertion = AssertionDefinition.creativeWork(data: [ + "@type": AnyCodable("CreativeWork"), + "author": AnyCodable(["@type": "Person", "name": "Test Author"] as [String: Any]) + ]) + do { + let data = try JSONEncoder().encode(assertion) + let decoded = try JSONDecoder().decode(AssertionDefinition.self, from: data) + if case .creativeWork(let decodedData) = decoded { + guard decodedData["@type"] != nil else { + return .failure("CreativeWork", "Missing @type in decoded data") + } + } else { + return .failure("CreativeWork", "Decoded as wrong type") + } + return .success("CreativeWork", "[PASS] Creative work assertion round-trip works") + } catch { + return .failure("CreativeWork", "Error: \(error)") + } + } + + // MARK: - AnyCodable Tests + + public func testAnyCodableTypes() -> TestResult { + let values: [(String, AnyCodable)] = [ + ("bool", AnyCodable(true)), + ("int", AnyCodable(42)), + ("double", AnyCodable(3.14)), + ("string", AnyCodable("hello")), + ("array", AnyCodable([1, 2, 3])), + ("dict", AnyCodable(["key": "value"] as [String: Any])) + ] + for (name, value) in values { + do { + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + guard value == decoded else { + return .failure("AnyCodable", "\(name) round-trip failed") + } + } catch { + return .failure("AnyCodable", "\(name) error: \(error)") + } + } + return .success("AnyCodable Types", "[PASS] All AnyCodable types round-trip correctly") + } + + public func testAnyCodableEquality() -> TestResult { + let a = AnyCodable("hello") + let b = AnyCodable("hello") + let c = AnyCodable("world") + guard a == b else { + return .failure("AnyCodable Equality", "Same values should be equal") + } + guard a != c else { + return .failure("AnyCodable Equality", "Different values should not be equal") + } + return .success("AnyCodable Equality", "[PASS] AnyCodable equality works") + } + + // MARK: - Actions V2 Label + + public func testActionsV2Decoding() -> TestResult { + let json = """ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [{"action": "c2pa.created"}] + } + } + """ + do { + let decoded = try JSONDecoder().decode(AssertionDefinition.self, from: json.data(using: .utf8)!) + if case .actions(let actions) = decoded { + guard actions.count == 1 else { + return .failure("Actions V2", "Expected 1 action") + } + } else { + return .failure("Actions V2", "Should decode as .actions") + } + return .success("Actions V2", "[PASS] c2pa.actions.v2 label decodes correctly") + } catch { + return .failure("Actions V2", "Error: \(error)") + } + } + public func runAllTests() async -> [TestResult] { var results: [TestResult] = [] @@ -377,6 +564,14 @@ public final class AssertionDefinitionTests: TestImplementation { results.append(testAllAssertionTypesEncoding()) results.append(testAllAssertionTypesRoundTrip()) results.append(testAssertionEquality()) + results.append(testCustomAssertionRoundTrip()) + results.append(testTrainingMiningAssertion()) + results.append(testCawgTrainingMiningAssertion()) + results.append(testCawgIdentityAssertion()) + results.append(testCreativeWorkAssertion()) + results.append(testAnyCodableTypes()) + results.append(testAnyCodableEquality()) + results.append(testActionsV2Decoding()) return results } diff --git a/TestShared/Sources/ManifestTests.swift b/TestShared/Sources/ManifestTests.swift index f8dbd25..fe54b95 100644 --- a/TestShared/Sources/ManifestTests.swift +++ b/TestShared/Sources/ManifestTests.swift @@ -9,8 +9,8 @@ public final class ManifestTests: TestImplementation { public func testMinimal() -> TestResult { let manifest = ManifestDefinition(claimGeneratorInfo: [], title: "test") - if manifest.claimVersion != 1 { - return .failure("Manifest", "claimVersion != 1, got \(manifest.claimVersion)") + if manifest.claimVersion != 2 { + return .failure("Manifest", "claimVersion != 2, got \(manifest.claimVersion)") } if manifest.format != "application/octet-stream" { @@ -273,6 +273,453 @@ public final class ManifestTests: TestImplementation { return .success("Mass Init", testSteps.joined(separator: "\n")) } + public func testNewPredefinedActions() -> TestResult { + let cases: [(PredefinedAction, String)] = [ + (.mastered, "c2pa.mastered"), + (.mixed, "c2pa.mixed"), + (.remixed, "c2pa.remixed"), + (.resizedProportional, "c2pa.resized.proportional"), + (.watermarkedBound, "c2pa.watermarked.bound"), + (.watermarkedUnbound, "c2pa.watermarked.unbound"), + (.fontCharactersAdded, "font.charactersAdded"), + (.fontCharactersDeleted, "font.charactersDeleted"), + (.fontCharactersModified, "font.charactersModified"), + (.fontCreatedFromVariableFont, "font.createdFromVariableFont"), + (.fontEdited, "font.edited"), + (.fontHinted, "font.hinted"), + (.fontMerged, "font.merged"), + (.fontOpenTypeFeatureAdded, "font.openTypeFeatureAdded"), + (.fontOpenTypeFeatureModified, "font.openTypeFeatureModified"), + (.fontOpenTypeFeatureRemoved, "font.openTypeFeatureRemoved"), + (.fontSubset, "font.subset") + ] + for (action, expected) in cases { + guard action.rawValue == expected else { + return .failure("PredefinedAction", "\(action) rawValue '\(action.rawValue)' != '\(expected)'") + } + } + return .success("PredefinedAction", "[PASS] All 17 new action cases verified") + } + + public func testActionV2SoftwareAgent() -> TestResult { + // Test v1 string softwareAgent + let v1Action = Action(action: "c2pa.created", softwareAgent: "MyApp/1.0") + guard v1Action.softwareAgentString == "MyApp/1.0" else { + return .failure("Action v2", "softwareAgentString should be 'MyApp/1.0', got '\(v1Action.softwareAgentString ?? "nil")'") + } + guard v1Action.softwareAgentInfo == nil else { + return .failure("Action v2", "softwareAgentInfo should be nil for v1 string agent") + } + + // Test v2 ClaimGeneratorInfo softwareAgent + let generatorInfo = ClaimGeneratorInfo(name: "TestApp", version: "2.0") + let v2Action = Action( + action: .created, + softwareAgentInfo: generatorInfo + ) + guard v2Action.softwareAgentString == nil else { + return .failure("Action v2", "softwareAgentString should be nil for v2 object agent") + } + guard let decoded = v2Action.softwareAgentInfo else { + return .failure("Action v2", "softwareAgentInfo should decode to ClaimGeneratorInfo") + } + guard decoded.name == "TestApp" else { + return .failure("Action v2", "softwareAgentInfo.name should be 'TestApp', got '\(decoded.name)'") + } + + return .success("Action v2", "[PASS] v1 string and v2 object softwareAgent verified") + } + + public func testActionNewFields() -> TestResult { + let action = Action( + action: "c2pa.created", + digitalSourceType: "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture", + softwareAgent: "TestApp", + when: "2026-03-12T10:00:00Z", + reason: "Initial capture" + ) + + guard action.when == "2026-03-12T10:00:00Z" else { + return .failure("Action Fields", "when mismatch") + } + guard action.reason == "Initial capture" else { + return .failure("Action Fields", "reason mismatch") + } + guard action.changes == nil else { + return .failure("Action Fields", "changes should be nil by default") + } + guard action.related == nil else { + return .failure("Action Fields", "related should be nil by default") + } + + // Test round-trip encoding/decoding + do { + let data = try JSONEncoder().encode(action) + let decoded = try JSONDecoder().decode(Action.self, from: data) + guard action == decoded else { + return .failure("Action Fields", "Round-trip encoding/decoding mismatch") + } + } catch { + return .failure("Action Fields", "Encoding error: \(error)") + } + + return .success("Action Fields", "[PASS] Action new fields and round-trip verified") + } + + public func testValidateAndLog() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo()], + title: "test" + ) + let result = ManifestValidator.validateAndLog(manifest) + guard result.isValid else { + return .failure("ValidateAndLog", "Valid manifest reported as invalid: \(result.errors)") + } + return .success("ValidateAndLog", "[PASS] validateAndLog works for valid manifest") + } + + public func testCustomAssertionLabelValidation() -> TestResult { + let manifest = ManifestDefinition( + assertions: [.custom(label: "nolabel", data: AnyCodable("test"))], + claimGeneratorInfo: [ClaimGeneratorInfo()], + title: "test" + ) + let result = ManifestValidator.validate(manifest) + guard result.warnings.contains(where: { $0.contains("namespaced format") }) else { + return .failure("Custom Label", "Expected warning about namespaced format, got: \(result.warnings)") + } + + // Verify properly namespaced label does not trigger warning + let manifest2 = ManifestDefinition( + assertions: [.custom(label: "com.example.test", data: AnyCodable("test"))], + claimGeneratorInfo: [ClaimGeneratorInfo()], + title: "test" + ) + let result2 = ManifestValidator.validate(manifest2) + guard !result2.warnings.contains(where: { $0.contains("namespaced format") }) else { + return .failure("Custom Label", "Should not warn for properly namespaced label") + } + + return .success("Custom Label", "[PASS] Custom assertion label validation verified") + } + + // MARK: - ManifestDefinition Factory Methods + + public func testCreatedFactory() -> TestResult { + let manifest = ManifestDefinition.created( + title: "photo.jpg", + claimGeneratorInfo: ClaimGeneratorInfo(name: "TestApp"), + digitalSourceType: .digitalCapture + ) + guard !manifest.assertions.isEmpty else { + return .failure("Created Factory", "Should have assertions") + } + if case .actions(let actions) = manifest.assertions.first { + guard actions.first?.action == PredefinedAction.created.rawValue else { + return .failure("Created Factory", "First action should be c2pa.created") + } + } else { + return .failure("Created Factory", "First assertion should be .actions") + } + return .success("Created Factory", "[PASS] ManifestDefinition.created() works") + } + + public func testEditedFactory() -> TestResult { + let parent = Ingredient.parent(title: "original.jpg") + let manifest = ManifestDefinition.edited( + title: "edited.jpg", + claimGeneratorInfo: ClaimGeneratorInfo(name: "TestApp"), + parentIngredient: parent, + editActions: [Action(action: PredefinedAction.cropped.rawValue)] + ) + guard manifest.ingredients.count == 1 else { + return .failure("Edited Factory", "Should have 1 ingredient") + } + guard manifest.ingredients.first?.relationship == .parentOf else { + return .failure("Edited Factory", "Ingredient should be parentOf") + } + return .success("Edited Factory", "[PASS] ManifestDefinition.edited() works") + } + + public func testMixedAssertions() -> TestResult { + let manifest = ManifestDefinition( + assertions: [ + .metadata, + .cawgIdentity(data: ["sig_type": AnyCodable("cawg.x509")]) + ], + claimGeneratorInfo: [ClaimGeneratorInfo(name: "TestApp")], + title: "test.jpg" + ) + guard manifest.assertions.count == 2 else { + return .failure("MixedAssertions", "Should have 2 assertions") + } + guard manifest.assertions[1].baseLabel == "cawg.identity" else { + return .failure("MixedAssertions", "Second assertion should be cawg.identity") + } + return .success("MixedAssertions", "[PASS] Mixed assertion types in single list works") + } + + // MARK: - ManifestDefinition Convenience Methods + + public func testAssertionLabels() -> TestResult { + let manifest = ManifestDefinition( + assertions: [.metadata, .metadata, .dataHash], + claimGeneratorInfo: [ClaimGeneratorInfo(name: "TestApp")], + title: "test.jpg" + ) + let labels = manifest.assertionLabels() + guard labels.contains("c2pa.metadata") else { + return .failure("AssertionLabels", "Should contain c2pa.metadata") + } + guard labels.contains("c2pa.hash.data") else { + return .failure("AssertionLabels", "Should contain c2pa.hash.data") + } + return .success("AssertionLabels", "[PASS] assertionLabels() works") + } + + public func testToJSON() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "json.jpg" + ) + do { + let json = try manifest.toJSON() + guard json.contains("json.jpg") else { + return .failure("toJSON", "JSON should contain title") + } + return .success("toJSON", "[PASS] toJSON() works") + } catch { + return .failure("toJSON", "Error: \(error)") + } + } + + public func testToPrettyJSON() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "pretty.jpg" + ) + do { + let json = try manifest.toPrettyJSON() + guard json.contains("\n") else { + return .failure("toPrettyJSON", "Pretty JSON should contain newlines") + } + return .success("toPrettyJSON", "[PASS] toPrettyJSON() works") + } catch { + return .failure("toPrettyJSON", "Error: \(error)") + } + } + + public func testFromJSON() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "fromjson.jpg" + ) + do { + let json = try manifest.toJSON() + let decoded = try ManifestDefinition.fromJSON(json) + guard decoded.title == "fromjson.jpg" else { + return .failure("fromJSON", "Title mismatch") + } + return .success("fromJSON", "[PASS] fromJSON() round-trip works") + } catch { + return .failure("fromJSON", "Error: \(error)") + } + } + + public func testDescription() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "desc.jpg" + ) + let desc = manifest.description + guard desc.contains("desc.jpg") else { + return .failure("Description", "description should contain title") + } + return .success("Description", "[PASS] CustomStringConvertible works") + } + + // MARK: - Ingredient Factory Methods + + public func testIngredientParentFactory() -> TestResult { + let ingredient = Ingredient.parent(title: "parent.jpg", format: "image/jpeg") + guard ingredient.relationship == .parentOf else { + return .failure("Ingredient.parent", "Should have parentOf relationship") + } + guard ingredient.title == "parent.jpg" else { + return .failure("Ingredient.parent", "Title mismatch") + } + return .success("Ingredient.parent", "[PASS] Ingredient.parent() works") + } + + public func testIngredientComponentFactory() -> TestResult { + let ingredient = Ingredient.component(title: "watermark.png") + guard ingredient.relationship == .componentOf else { + return .failure("Ingredient.component", "Should have componentOf relationship") + } + return .success("Ingredient.component", "[PASS] Ingredient.component() works") + } + + public func testIngredientInputToFactory() -> TestResult { + let ingredient = Ingredient.inputTo(title: "training.jpg") + guard ingredient.relationship == .inputTo else { + return .failure("Ingredient.inputTo", "Should have inputTo relationship") + } + return .success("Ingredient.inputTo", "[PASS] Ingredient.inputTo() works") + } + + // MARK: - ManifestValidator Coverage + + public func testValidatorEmptyTitle() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "" + ) + let result = ManifestValidator.validate(manifest) + guard result.errors.contains(where: { $0.contains("title") }) else { + return .failure("Empty Title", "Expected title error, got: \(result.errors)") + } + return .success("Empty Title", "[PASS] Empty title produces error") + } + + public func testValidatorEmptyClaimGeneratorInfo() -> TestResult { + let manifest = ManifestDefinition(claimGeneratorInfo: [], title: "test") + let result = ManifestValidator.validate(manifest) + guard result.errors.contains(where: { $0.contains("claim_generator_info") }) else { + return .failure("Empty CGI", "Expected CGI error, got: \(result.errors)") + } + return .success("Empty CGI", "[PASS] Empty claimGeneratorInfo produces error") + } + + public func testValidatorOldClaimVersion() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + claimVersion: 1, + title: "test" + ) + let result = ManifestValidator.validate(manifest) + guard result.warnings.contains(where: { $0.contains("outdated") }) else { + return .failure("Old Version", "Expected version warning, got: \(result.warnings)") + } + return .success("Old Version", "[PASS] Old claim version produces warning") + } + + public func testValidatorDeprecatedAssertionLabels() -> TestResult { + let manifest = ManifestDefinition( + assertions: [.custom(label: "stds.exif", data: AnyCodable("test"))], + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "test" + ) + let result = ManifestValidator.validate(manifest) + guard result.warnings.contains(where: { $0.contains("Deprecated") && $0.contains("stds.exif") }) else { + return .failure("Deprecated Labels", "Expected deprecated warning, got: \(result.warnings)") + } + return .success("Deprecated Labels", "[PASS] Deprecated labels produce warnings") + } + + public func testValidatorCawgAssertionAccepted() -> TestResult { + let manifest = ManifestDefinition( + assertions: [.cawgIdentity(data: ["test": AnyCodable("value")])], + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "test" + ) + let result = ManifestValidator.validate(manifest) + guard result.isValid else { + return .failure("CAWG Assertion", "CAWG identity in assertions should be valid, got errors: \(result.errors)") + } + return .success("CAWG Assertion", "[PASS] CAWG identity assertion accepted in assertions list") + } + + public func testValidatorMultipleParents() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + ingredients: [ + .parent(title: "parent1.jpg"), + .parent(title: "parent2.jpg") + ], + title: "test" + ) + let result = ManifestValidator.validate(manifest) + guard result.warnings.contains(where: { $0.contains("Multiple parent") }) else { + return .failure("Multiple Parents", "Expected multiple parent warning, got: \(result.warnings)") + } + return .success("Multiple Parents", "[PASS] Multiple parent ingredients produce warning") + } + + public func testValidateJSON() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [ClaimGeneratorInfo(name: "test")], + title: "test" + ) + do { + let json = try manifest.toJSON() + let result = ManifestValidator.validateJSON(json) + guard result.isValid else { + return .failure("ValidateJSON", "Valid JSON should validate, got errors: \(result.errors)") + } + return .success("ValidateJSON", "[PASS] validateJSON() works") + } catch { + return .failure("ValidateJSON", "Error: \(error)") + } + } + + public func testValidateJSONInvalid() -> TestResult { + let result = ManifestValidator.validateJSON("not valid json {{{") + guard !result.isValid else { + return .failure("ValidateJSON Invalid", "Invalid JSON should not validate") + } + return .success("ValidateJSON Invalid", "[PASS] Invalid JSON fails validateJSON()") + } + + // MARK: - Builder Validation Integration Tests + + public func testBuilderInitManifestValid() -> TestResult { + let manifest = ManifestDefinition.created( + title: "test.jpg", + claimGeneratorInfo: ClaimGeneratorInfo(name: "TestApp", version: "1.0"), + digitalSourceType: .digitalCapture + ) + do { + _ = try Builder(manifest: manifest) + return .success("Builder init(manifest:)", "[PASS] Valid manifest creates builder") + } catch { + return .failure("Builder init(manifest:)", "Should not throw for valid manifest: \(error)") + } + } + + public func testBuilderInitManifestInvalid() -> TestResult { + let manifest = ManifestDefinition( + claimGeneratorInfo: [], + title: "" + ) + do { + _ = try Builder(manifest: manifest) + return .failure("Builder init(manifest:) invalid", "Should have thrown for invalid manifest") + } catch let error as C2PAError { + if case .manifestValidationFailed(let result) = error { + guard result.hasErrors else { + return .failure("Builder init(manifest:) invalid", "Result should have errors") + } + return .success("Builder init(manifest:) invalid", "[PASS] Invalid manifest throws manifestValidationFailed") + } + return .failure("Builder init(manifest:) invalid", "Wrong error type: \(error)") + } catch { + return .failure("Builder init(manifest:) invalid", "Unexpected error: \(error)") + } + } + + public func testBuilderInitJSONInvalid() -> TestResult { + // init(manifestJSON:) does not validate -- it delegates to the C layer, + // which should reject invalid JSON with a C2PAError. + do { + _ = try Builder(manifestJSON: "not valid json") + return .failure("Builder init(manifestJSON:) invalid", "Should have thrown for invalid JSON") + } catch is C2PAError { + return .success("Builder init(manifestJSON:) invalid", "[PASS] Invalid JSON throws C2PAError") + } catch { + return .failure("Builder init(manifestJSON:) invalid", "Unexpected error type: \(error)") + } + } + @MainActor public func runAllTests() async -> [TestResult] { return [ @@ -283,7 +730,34 @@ public final class ManifestTests: TestImplementation { testResourceRef(), testHashedUri(), testUriOrResource(), - testMassInit() + testMassInit(), + testNewPredefinedActions(), + testActionV2SoftwareAgent(), + testActionNewFields(), + testValidateAndLog(), + testCustomAssertionLabelValidation(), + testCreatedFactory(), + testEditedFactory(), + testMixedAssertions(), + testAssertionLabels(), + testToJSON(), + testToPrettyJSON(), + testFromJSON(), + testDescription(), + testIngredientParentFactory(), + testIngredientComponentFactory(), + testIngredientInputToFactory(), + testValidatorEmptyTitle(), + testValidatorEmptyClaimGeneratorInfo(), + testValidatorOldClaimVersion(), + testValidatorDeprecatedAssertionLabels(), + testValidatorCawgAssertionAccepted(), + testValidatorMultipleParents(), + testValidateJSON(), + testValidateJSONInvalid(), + testBuilderInitManifestValid(), + testBuilderInitManifestInvalid(), + testBuilderInitJSONInvalid() ] }