Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions Library/Sources/Builder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Foundation
/// ## Topics
///
/// ### Creating a Builder
/// - ``init(manifest:)``
/// - ``init(manifestJSON:)``
/// - ``init(archiveStream:)``
///
Expand Down Expand Up @@ -65,13 +66,45 @@ import Foundation
public final class Builder {
private let ptr: UnsafeMutablePointer<C2paBuilder>

/// 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.
Expand Down
9 changes: 9 additions & 0 deletions Library/Sources/C2PAError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import Foundation
/// - ``publicKeyExtractionFailed``
/// - ``publicKeyExportFailed(_:)``
/// - ``asyncSigningFailed``
/// - ``manifestValidationFailed(_:)``
public enum C2PAError: Error, LocalizedError {
/// An error reported by the underlying C2PA library.
///
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "; "))"
}
}
}
2 changes: 1 addition & 1 deletion Library/Sources/C2PAJson.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion Library/Sources/Intent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
177 changes: 169 additions & 8 deletions Library/Sources/Manifest/Action.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,54 +13,215 @@

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

/// A URL identifying an IPTC term. Most probably a ``DigitalSourceType``.
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
}
}
Loading
Loading