From 506dda65c8f95ff83a0ea2251735beea9806a003 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Wed, 25 Mar 2026 20:22:30 +0000 Subject: [PATCH] Add settings APIs: C2PASettings and SettingsDefinition --- .github/workflows/check-schema-changes.yml | 153 ++++ Library/Library.xcodeproj/project.pbxproj | 8 + Library/Sources/C2PASettings.swift | 199 +++++ Library/Sources/SettingsDefinition.swift | 792 ++++++++++++++++++ Library/Tests/LibraryTestRunner.swift | 71 ++ .../Sources/SettingsDefinitionTests.swift | 688 +++++++++++++++ .../TestShared.xcodeproj/project.pbxproj | 5 + 7 files changed, 1916 insertions(+) create mode 100644 .github/workflows/check-schema-changes.yml create mode 100644 Library/Sources/C2PASettings.swift create mode 100644 Library/Sources/SettingsDefinition.swift create mode 100644 TestShared/Sources/SettingsDefinitionTests.swift diff --git a/.github/workflows/check-schema-changes.yml b/.github/workflows/check-schema-changes.yml new file mode 100644 index 0000000..1763db1 --- /dev/null +++ b/.github/workflows/check-schema-changes.yml @@ -0,0 +1,153 @@ +name: Check C2PA schema changes + +on: + schedule: + - cron: "30 */6 * * *" + workflow_dispatch: + +jobs: + check-schema: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Get current and previous c2pa-rs tags + id: get_tags + run: | + TAGS=$(curl -s "https://api.github.com/repos/contentauth/c2pa-rs/releases" | \ + jq -r '.[].tag_name | select(test("^c2pa-v"))' | \ + head -2) + CURRENT_TAG=$(echo "$TAGS" | head -1) + PREVIOUS_TAG=$(echo "$TAGS" | tail -1) + if [ -z "$CURRENT_TAG" ] || [ -z "$PREVIOUS_TAG" ]; then + echo "Could not find two consecutive c2pa release tags" + exit 1 + fi + if [ "$CURRENT_TAG" = "$PREVIOUS_TAG" ]; then + echo "Only one tag found, nothing to compare" + exit 1 + fi + echo "current_tag=$CURRENT_TAG" >> $GITHUB_OUTPUT + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + + - name: Clone c2pa-rs + run: | + git clone https://github.com/contentauth/c2pa-rs.git /tmp/c2pa-rs + cd /tmp/c2pa-rs + git fetch --tags + + - name: Generate schemas for both tags + run: | + for TAG_VAR in current previous; do + if [ "$TAG_VAR" = "current" ]; then + TAG="${{ steps.get_tags.outputs.current_tag }}" + else + TAG="${{ steps.get_tags.outputs.previous_tag }}" + fi + cd /tmp/c2pa-rs + git checkout "$TAG" + cargo run -p export_schema --features c2pa/rust_native_crypto 2>/dev/null || \ + cargo run -p export_schema 2>/dev/null + mkdir -p "/tmp/schemas-$TAG_VAR" + cp target/schema/*.schema.json "/tmp/schemas-$TAG_VAR/" + done + + - name: Compare schemas + id: compare + run: | + CHANGES="" + HAS_CHANGES=false + + for schema in /tmp/schemas-current/*.schema.json; do + NAME=$(basename "$schema") + PREV="/tmp/schemas-previous/$NAME" + + if [ ! -f "$PREV" ]; then + CHANGES="${CHANGES}- **${NAME}**: New schema\n" + HAS_CHANGES=true + continue + fi + + DIFF=$(diff \ + <(python3 -c "import json,sys; json.dump(json.load(open('$PREV')),sys.stdout,sort_keys=True,indent=2)") \ + <(python3 -c "import json,sys; json.dump(json.load(open('$schema')),sys.stdout,sort_keys=True,indent=2)") \ + || true) + + if [ -n "$DIFF" ]; then + ADDED=$(echo "$DIFF" | grep -c '^>' || true) + REMOVED=$(echo "$DIFF" | grep -c '^<' || true) + CHANGES="${CHANGES}- **${NAME}**: ${ADDED} additions, ${REMOVED} removals\n" + HAS_CHANGES=true + fi + done + + for schema in /tmp/schemas-previous/*.schema.json; do + NAME=$(basename "$schema") + if [ ! -f "/tmp/schemas-current/$NAME" ]; then + CHANGES="${CHANGES}- **${NAME}**: Schema removed\n" + HAS_CHANGES=true + fi + done + + echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT + if [ "$HAS_CHANGES" = true ]; then + echo "changes<> $GITHUB_OUTPUT + echo -e "$CHANGES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + - name: Ensure schema-change label exists + if: steps.compare.outputs.has_changes == 'true' + run: | + gh label create "schema-change" \ + --description "Upstream c2pa-rs JSON schema has changed" \ + --color "D93F0B" \ + 2>/dev/null || true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for existing open issue + if: steps.compare.outputs.has_changes == 'true' + id: existing_issue + run: | + EXISTING=$(gh issue list \ + --label "schema-change" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + echo "number=$EXISTING" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: File issue for schema changes + if: steps.compare.outputs.has_changes == 'true' && steps.existing_issue.outputs.number == '' + run: | + gh issue create \ + --title "C2PA schema changes detected between ${{ steps.get_tags.outputs.previous_tag }} and ${{ steps.get_tags.outputs.current_tag }}" \ + --label "schema-change" \ + --body "$(cat <<'ISSUE_EOF' + ## Schema Changes Detected + + Changes were detected in the upstream c2pa-rs JSON schemas between \ + `${{ steps.get_tags.outputs.previous_tag }}` and `${{ steps.get_tags.outputs.current_tag }}`. + + ### Changed Schemas + + ${{ steps.compare.outputs.changes }} + + ### Action Required + + Review the upstream schema changes and update `C2PASettingsDefinition` in \ + `Library/Sources/Manifest/SettingsDefinition.swift` to match the new schema. + + See the [c2pa-rs releases](https://github.com/contentauth/c2pa-rs/releases) for details. + ISSUE_EOF + )" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Library/Library.xcodeproj/project.pbxproj b/Library/Library.xcodeproj/project.pbxproj index 6094ca2..9099e7c 100644 --- a/Library/Library.xcodeproj/project.pbxproj +++ b/Library/Library.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ A0C2A0022E96B00100F2F938 /* C2PAError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C2A0012E96B00100F2F938 /* C2PAError.swift */; }; A0C2A0042E96B00100F2F938 /* C2PAJson.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C2A0032E96B00100F2F938 /* C2PAJson.swift */; }; A0C2A0062E96B00100F2F938 /* SignerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C2A0052E96B00100F2F938 /* SignerInfo.swift */; }; + A0C2A0082E96B00100F2F938 /* C2PASettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C2A0072E96B00100F2F938 /* C2PASettings.swift */; }; + A0C2A00A2E96B00100F2F938 /* SettingsDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C2A0092E96B00100F2F938 /* SettingsDefinition.swift */; }; 953415F32E6EB31400A97957 /* Base.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 953415F12E6EB31400A97957 /* Base.xcconfig */; }; 9534697E2E4CB081003E83AD /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 9534697D2E4CB081003E83AD /* X509 */; }; 953469812E4CB0A8003E83AD /* Crypto in Frameworks */ = {isa = PBXBuildFile; productRef = 953469802E4CB0A8003E83AD /* Crypto */; }; @@ -59,6 +61,8 @@ A0C2A0012E96B00100F2F938 /* C2PAError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = C2PAError.swift; sourceTree = ""; }; A0C2A0032E96B00100F2F938 /* C2PAJson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = C2PAJson.swift; sourceTree = ""; }; A0C2A0052E96B00100F2F938 /* SignerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignerInfo.swift; sourceTree = ""; }; + A0C2A0072E96B00100F2F938 /* C2PASettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = C2PASettings.swift; sourceTree = ""; }; + A0C2A0092E96B00100F2F938 /* SettingsDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDefinition.swift; sourceTree = ""; }; 953415F12E6EB31400A97957 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; 953469D92E4D2578003E83AD /* C2PATests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = C2PATests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 953469E52E4D25C6003E83AD /* TestShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TestShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -141,6 +145,8 @@ 95A782322E6F06E4001E9748 /* Builder.swift */, A0C2A0012E96B00100F2F938 /* C2PAError.swift */, A0C2A0032E96B00100F2F938 /* C2PAJson.swift */, + A0C2A0072E96B00100F2F938 /* C2PASettings.swift */, + A0C2A0092E96B00100F2F938 /* SettingsDefinition.swift */, 8A1234571234567890ABCDEF /* CertificateManager.swift */, 95A782342E6F06E4001E9748 /* Helpers.swift */, 95A782352E6F06E4001E9748 /* KeychainSigner.swift */, @@ -343,6 +349,8 @@ 95A7823D2E6F06E4001E9748 /* Builder.swift in Sources */, A0C2A0022E96B00100F2F938 /* C2PAError.swift in Sources */, A0C2A0042E96B00100F2F938 /* C2PAJson.swift in Sources */, + A0C2A0082E96B00100F2F938 /* C2PASettings.swift in Sources */, + A0C2A00A2E96B00100F2F938 /* SettingsDefinition.swift in Sources */, 95A7823E2E6F06E4001E9748 /* KeychainSigner.swift in Sources */, 95A7823F2E6F06E4001E9748 /* Signer.swift in Sources */, 95A782402E6F06E4001E9748 /* Reader.swift in Sources */, diff --git a/Library/Sources/C2PASettings.swift b/Library/Sources/C2PASettings.swift new file mode 100644 index 0000000..12e3d74 --- /dev/null +++ b/Library/Sources/C2PASettings.swift @@ -0,0 +1,199 @@ +// 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. +// +// C2PASettings.swift +// + +import C2PAC +import Foundation + +/// Manages C2PA settings configuration. +/// +/// `C2PASettings` provides a Swift-idiomatic interface for loading and applying +/// C2PA settings in JSON or TOML format. Settings control signer configuration, +/// CAWG identity assertions, thumbnail generation, and other build options. +/// +/// Settings can be loaded from raw JSON/TOML strings or from a type-safe +/// ``C2PASettingsDefinition`` struct. +/// +/// ## Example +/// +/// ```swift +/// // From a raw JSON string +/// let settings = try C2PASettings(json: settingsJSON) +/// +/// // From a type-safe definition +/// let definition = C2PASettingsDefinition( +/// version: 1, +/// signer: .local(LocalSignerSettings( +/// alg: "es256", +/// signCert: certPEM, +/// privateKey: keyPEM +/// )) +/// ) +/// let settings = try C2PASettings(definition: definition) +/// ``` +/// +/// - SeeAlso: ``Signer``, ``C2PASettingsDefinition`` +public final class C2PASettings { + private var settingsString: String + private var format: String + + /// Creates settings from a JSON string. + /// + /// - Parameter json: A JSON string containing C2PA settings. + /// - Throws: ``C2PAError`` if the JSON is invalid. + public init(json: String) throws { + self.settingsString = json + self.format = "json" + try apply() + } + + /// Creates settings from a TOML string. + /// + /// - Parameter toml: A TOML string containing C2PA settings. + /// - Throws: ``C2PAError`` if the TOML is invalid. + public init(toml: String) throws { + self.settingsString = toml + self.format = "toml" + try apply() + } + + /// Creates settings from a type-safe ``C2PASettingsDefinition``. + /// + /// The definition is encoded to JSON and applied to the C2PA runtime. + /// + /// - Parameter definition: A settings definition struct. + /// - Throws: ``C2PAError`` if the settings are invalid. + /// + /// - SeeAlso: ``C2PASettingsDefinition`` + public init(definition: C2PASettingsDefinition) throws { + self.settingsString = try C2PAJson.encode(definition) + self.format = "json" + try apply() + } + + /// Loads additional JSON settings, merging with existing configuration. + /// + /// - Parameter json: A JSON string containing C2PA settings to merge. + /// - Throws: ``C2PAError`` if the JSON is invalid. + public func load(json: String) throws { + self.settingsString = json + self.format = "json" + try apply() + } + + /// Loads additional TOML settings, merging with existing configuration. + /// + /// - Parameter toml: A TOML string containing C2PA settings to merge. + /// - Throws: ``C2PAError`` if the TOML is invalid. + public func load(toml: String) throws { + self.settingsString = toml + self.format = "toml" + try apply() + } + + /// Loads settings from a type-safe ``C2PASettingsDefinition``, + /// merging with existing configuration. + /// + /// - Parameter definition: A settings definition struct. + /// - Throws: ``C2PAError`` if the settings are invalid. + public func load(definition: C2PASettingsDefinition) throws { + self.settingsString = try C2PAJson.encode(definition) + self.format = "json" + try apply() + } + + /// Sets a single value at the given dot-separated path within the settings. + /// + /// This method parses the current JSON settings, navigates to the specified + /// path, sets the value, and re-applies the updated settings. + /// + /// - Parameters: + /// - value: The value to set. Must be a JSON-compatible type + /// (`String`, `Int`, `Double`, `Bool`, or `nil`). + /// - path: A dot-separated path (e.g., `"builder.thumbnail.format"`). + /// + /// - Throws: ``C2PAError`` if the format is not JSON or the path is invalid. + /// + /// ## Example + /// + /// ```swift + /// let settings = try C2PASettings(json: "{\"version\": 1}") + /// try settings.setValue("es256", forPath: "signer.local.alg") + /// ``` + public func setValue(_ value: Any, forPath path: String) throws { + guard format == "json" else { + throw C2PAError.api("setValue is only supported for JSON settings") + } + + guard let data = settingsString.data(using: .utf8), + var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw C2PAError.api("Current settings are not a valid JSON object") + } + + let components = path.split(separator: ".").map(String.init) + guard !components.isEmpty else { + throw C2PAError.api("Path must not be empty") + } + + setNestedValue(&json, components: components, value: value) + + let updatedData = try JSONSerialization.data(withJSONObject: json) + guard let updatedString = String(data: updatedData, encoding: .utf8) else { + throw C2PAError.utf8 + } + + self.settingsString = updatedString + try apply() + } + + /// Creates a ``Signer`` from the loaded settings. + /// + /// - Returns: A configured ``Signer`` instance. + /// - Throws: ``C2PAError`` if a signer cannot be created from the settings. + public func createSigner() throws -> Signer { + if format == "json" { + return try Signer(settingsJSON: settingsString) + } else { + return try Signer(settingsTOML: settingsString) + } + } + + // MARK: - Private + + private func apply() throws { + try settingsString.withCString { settingsPtr in + try format.withCString { formatPtr in + let result = c2pa_load_settings(settingsPtr, formatPtr) + guard result == 0 else { + throw C2PAError.api(lastC2PAError()) + } + } + } + } + + private func setNestedValue( + _ dict: inout [String: Any], + components: [String], + value: Any + ) { + guard let key = components.first else { return } + + if components.count == 1 { + dict[key] = value + } else { + var nested = dict[key] as? [String: Any] ?? [:] + setNestedValue(&nested, components: Array(components.dropFirst()), value: value) + dict[key] = nested + } + } +} diff --git a/Library/Sources/SettingsDefinition.swift b/Library/Sources/SettingsDefinition.swift new file mode 100644 index 0000000..d7c5cf6 --- /dev/null +++ b/Library/Sources/SettingsDefinition.swift @@ -0,0 +1,792 @@ +// 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. +// +// SettingsDefinition.swift +// + +import Foundation + +// MARK: - Top-Level Settings + +/// A type-safe representation of the C2PA settings JSON schema. +/// +/// Use `C2PASettingsDefinition` to construct settings programmatically with +/// compile-time type checking rather than building raw JSON strings. +/// +/// All properties are optional. Only set the values you need; unset properties +/// are omitted from the encoded JSON. +/// +/// ## Example +/// +/// ```swift +/// let definition = C2PASettingsDefinition( +/// version: 1, +/// signer: .local(LocalSignerSettings( +/// alg: "es256", +/// signCert: certPEM, +/// privateKey: keyPEM, +/// tsaUrl: "http://timestamp.digicert.com" +/// )) +/// ) +/// +/// let settings = try C2PASettings(definition: definition) +/// ``` +/// +/// - SeeAlso: ``C2PASettings/init(definition:)`` +public struct C2PASettingsDefinition: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case version + case trust + case cawgTrust = "cawg_trust" + case core + case verify + case builder + case signer + case cawgX509Signer = "cawg_x509_signer" + } + + /// The settings schema version. Currently `1`. + public var version: Int? + + /// Trust configuration for manifest verification. + public var trust: TrustSettings? + + /// CAWG-specific trust configuration. + public var cawgTrust: TrustSettings? + + /// Core library settings. + public var core: CoreSettings? + + /// Verification behavior settings. + public var verify: VerifySettings? + + /// Builder configuration. + public var builder: BuilderSettingsDefinition? + + /// Signer configuration (local or remote). + public var signer: SignerSettings? + + /// CAWG X.509 signer configuration. + public var cawgX509Signer: SignerSettings? + + public init( + version: Int? = nil, + trust: TrustSettings? = nil, + cawgTrust: TrustSettings? = nil, + core: CoreSettings? = nil, + verify: VerifySettings? = nil, + builder: BuilderSettingsDefinition? = nil, + signer: SignerSettings? = nil, + cawgX509Signer: SignerSettings? = nil + ) { + self.version = version + self.trust = trust + self.cawgTrust = cawgTrust + self.core = core + self.verify = verify + self.builder = builder + self.signer = signer + self.cawgX509Signer = cawgX509Signer + } + + /// Decodes a `C2PASettingsDefinition` from a JSON string. + /// + /// - Parameter json: A JSON string containing settings. + /// - Returns: The decoded settings definition. + /// - Throws: `DecodingError` if the JSON is invalid. + public static func fromJSON(_ json: String) throws -> C2PASettingsDefinition { + try C2PAJson.decode(C2PASettingsDefinition.self, from: json) + } + + /// Encodes this settings definition to a JSON string. + /// + /// - Returns: A compact JSON string. + /// - Throws: `EncodingError` if encoding fails. + public func toJSON() throws -> String { + try C2PAJson.encode(self) + } + + /// Encodes this settings definition to a pretty-printed JSON string. + /// + /// - Returns: A formatted JSON string. + /// - Throws: `EncodingError` if encoding fails. + public func toPrettyJSON() throws -> String { + try C2PAJson.encodePretty(self) + } +} + +// MARK: - Trust Settings + +/// Trust configuration for manifest verification. +public struct TrustSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case verifyTrustList = "verify_trust_list" + case userAnchors = "user_anchors" + case trustAnchors = "trust_anchors" + case trustConfig = "trust_config" + case allowedList = "allowed_list" + } + + /// Whether to verify against the trust list. + public var verifyTrustList: Bool? + + /// User-provided trust anchors in PEM format. + public var userAnchors: String? + + /// Trust anchors in PEM format. + public var trustAnchors: String? + + /// Trust configuration JSON. + public var trustConfig: String? + + /// Allowed list of signing credentials. + public var allowedList: String? + + public init( + verifyTrustList: Bool? = nil, + userAnchors: String? = nil, + trustAnchors: String? = nil, + trustConfig: String? = nil, + allowedList: String? = nil + ) { + self.verifyTrustList = verifyTrustList + self.userAnchors = userAnchors + self.trustAnchors = trustAnchors + self.trustConfig = trustConfig + self.allowedList = allowedList + } +} + +// MARK: - Core Settings + +/// Core library configuration. +public struct CoreSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case merkleTreeChunkSizeInKb = "merkle_tree_chunk_size_in_kb" + case merkleTreeMaxProofs = "merkle_tree_max_proofs" + case backingStoreMemoryThresholdInMb = "backing_store_memory_threshold_in_mb" + case decodeIdentityAssertions = "decode_identity_assertions" + case allowedNetworkHosts = "allowed_network_hosts" + } + + /// Chunk size for Merkle tree hashing, in kilobytes. + public var merkleTreeChunkSizeInKb: Int? + + /// Maximum number of Merkle tree proofs. + public var merkleTreeMaxProofs: Int? + + /// Memory threshold before switching to disk-backed storage, in megabytes. + public var backingStoreMemoryThresholdInMb: Int? + + /// Whether to decode identity assertions. + public var decodeIdentityAssertions: Bool? + + /// List of allowed network hosts for remote operations. + public var allowedNetworkHosts: [String]? + + public init( + merkleTreeChunkSizeInKb: Int? = nil, + merkleTreeMaxProofs: Int? = nil, + backingStoreMemoryThresholdInMb: Int? = nil, + decodeIdentityAssertions: Bool? = nil, + allowedNetworkHosts: [String]? = nil + ) { + self.merkleTreeChunkSizeInKb = merkleTreeChunkSizeInKb + self.merkleTreeMaxProofs = merkleTreeMaxProofs + self.backingStoreMemoryThresholdInMb = backingStoreMemoryThresholdInMb + self.decodeIdentityAssertions = decodeIdentityAssertions + self.allowedNetworkHosts = allowedNetworkHosts + } +} + +// MARK: - Verify Settings + +/// Verification behavior configuration. +public struct VerifySettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case verifyAfterReading = "verify_after_reading" + case verifyAfterSign = "verify_after_sign" + case verifyTrust = "verify_trust" + case verifyTimestampTrust = "verify_timestamp_trust" + case ocspFetch = "ocsp_fetch" + case remoteManifestFetch = "remote_manifest_fetch" + case skipIngredientConflictResolution = "skip_ingredient_conflict_resolution" + case strictV1Validation = "strict_v1_validation" + } + + /// Whether to verify manifests after reading. + public var verifyAfterReading: Bool? + + /// Whether to verify manifests after signing. + public var verifyAfterSign: Bool? + + /// Whether to verify trust chains. + public var verifyTrust: Bool? + + /// Whether to verify timestamp trust. + public var verifyTimestampTrust: Bool? + + /// Whether to fetch OCSP responses. + public var ocspFetch: Bool? + + /// Whether to fetch remote manifests. + public var remoteManifestFetch: Bool? + + /// Whether to skip ingredient conflict resolution. + public var skipIngredientConflictResolution: Bool? + + /// Whether to use strict v1 validation rules. + public var strictV1Validation: Bool? + + public init( + verifyAfterReading: Bool? = nil, + verifyAfterSign: Bool? = nil, + verifyTrust: Bool? = nil, + verifyTimestampTrust: Bool? = nil, + ocspFetch: Bool? = nil, + remoteManifestFetch: Bool? = nil, + skipIngredientConflictResolution: Bool? = nil, + strictV1Validation: Bool? = nil + ) { + self.verifyAfterReading = verifyAfterReading + self.verifyAfterSign = verifyAfterSign + self.verifyTrust = verifyTrust + self.verifyTimestampTrust = verifyTimestampTrust + self.ocspFetch = ocspFetch + self.remoteManifestFetch = remoteManifestFetch + self.skipIngredientConflictResolution = skipIngredientConflictResolution + self.strictV1Validation = strictV1Validation + } +} + +// MARK: - Builder Settings + +/// Builder configuration within settings. +public struct BuilderSettingsDefinition: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case vendor + case claimGeneratorInfo = "claim_generator_info" + case thumbnail + case actions + case certificateStatusFetch = "certificate_status_fetch" + case certificateStatusShouldOverride = "certificate_status_should_override" + case intent + case createdAssertionLabels = "created_assertion_labels" + case preferBoxHash = "prefer_box_hash" + case generateC2paArchive = "generate_c2pa_archive" + case autoTimestampAssertion = "auto_timestamp_assertion" + } + + /// Vendor prefix for manifest labels. + public var vendor: String? + + /// Claim generator information. + public var claimGeneratorInfo: ClaimGeneratorInfoSettings? + + /// Thumbnail generation settings. + public var thumbnail: ThumbnailSettings? + + /// Actions configuration. + public var actions: ActionsSettings? + + /// Scope for fetching certificate status (OCSP). + public var certificateStatusFetch: OcspFetchScope? + + /// Whether certificate status should override existing status. + public var certificateStatusShouldOverride: Bool? + + /// The intent for building manifests. + public var intent: SettingsIntent? + + /// Labels of assertions considered "created" by the builder. + public var createdAssertionLabels: [String]? + + /// Whether to prefer box hash for large assets. + public var preferBoxHash: Bool? + + /// Whether to generate a C2PA archive. + public var generateC2paArchive: Bool? + + /// Automatic timestamp assertion settings. + public var autoTimestampAssertion: TimeStampSettings? + + public init( + vendor: String? = nil, + claimGeneratorInfo: ClaimGeneratorInfoSettings? = nil, + thumbnail: ThumbnailSettings? = nil, + actions: ActionsSettings? = nil, + certificateStatusFetch: OcspFetchScope? = nil, + certificateStatusShouldOverride: Bool? = nil, + intent: SettingsIntent? = nil, + createdAssertionLabels: [String]? = nil, + preferBoxHash: Bool? = nil, + generateC2paArchive: Bool? = nil, + autoTimestampAssertion: TimeStampSettings? = nil + ) { + self.vendor = vendor + self.claimGeneratorInfo = claimGeneratorInfo + self.thumbnail = thumbnail + self.actions = actions + self.certificateStatusFetch = certificateStatusFetch + self.certificateStatusShouldOverride = certificateStatusShouldOverride + self.intent = intent + self.createdAssertionLabels = createdAssertionLabels + self.preferBoxHash = preferBoxHash + self.generateC2paArchive = generateC2paArchive + self.autoTimestampAssertion = autoTimestampAssertion + } +} + +// MARK: - Claim Generator Info Settings + +/// Claim generator information for settings. +public struct ClaimGeneratorInfoSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case name + case version + case operatingSystem = "operating_system" + } + + /// The name of the claim generator. + public var name: String + + /// The version of the claim generator. + public var version: String? + + /// The operating system the claim generator runs on. + public var operatingSystem: String? + + public init( + name: String, + version: String? = nil, + operatingSystem: String? = nil + ) { + self.name = name + self.version = version + self.operatingSystem = operatingSystem + } +} + +// MARK: - Thumbnail Settings + +/// Thumbnail generation configuration. +public struct ThumbnailSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case enabled + case ignoreErrors = "ignore_errors" + case longEdge = "long_edge" + case format + case preferSmallestFormat = "prefer_smallest_format" + case quality + } + + /// Whether thumbnail generation is enabled. + public var enabled: Bool? + + /// Whether to ignore errors during thumbnail generation. + public var ignoreErrors: Bool? + + /// The long edge dimension in pixels. + public var longEdge: Int? + + /// The image format for generated thumbnails. + public var format: ThumbnailFormat? + + /// Whether to prefer the smallest format. + public var preferSmallestFormat: Bool? + + /// The quality level for generated thumbnails. + public var quality: ThumbnailQuality? + + public init( + enabled: Bool? = nil, + ignoreErrors: Bool? = nil, + longEdge: Int? = nil, + format: ThumbnailFormat? = nil, + preferSmallestFormat: Bool? = nil, + quality: ThumbnailQuality? = nil + ) { + self.enabled = enabled + self.ignoreErrors = ignoreErrors + self.longEdge = longEdge + self.format = format + self.preferSmallestFormat = preferSmallestFormat + self.quality = quality + } +} + +/// Thumbnail image format. +public enum ThumbnailFormat: String, Codable, Sendable { + case png + case jpeg + case gif + case webp + case tiff +} + +/// Thumbnail quality level. +public enum ThumbnailQuality: String, Codable, Sendable { + case low + case medium + case high +} + +// MARK: - Actions Settings + +/// Actions configuration for the builder. +public struct ActionsSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case allActionsIncluded = "all_actions_included" + case templates + case autoCreatedAction = "auto_created_action" + case autoOpenedAction = "auto_opened_action" + case autoPlacedAction = "auto_placed_action" + } + + /// Whether all actions are included. + public var allActionsIncluded: Bool? + + /// Action templates. + public var templates: [ActionTemplateSettings]? + + /// Auto-created action settings. + public var autoCreatedAction: AutoActionSettings? + + /// Auto-opened action settings. + public var autoOpenedAction: AutoActionSettings? + + /// Auto-placed action settings. + public var autoPlacedAction: AutoActionSettings? + + public init( + allActionsIncluded: Bool? = nil, + templates: [ActionTemplateSettings]? = nil, + autoCreatedAction: AutoActionSettings? = nil, + autoOpenedAction: AutoActionSettings? = nil, + autoPlacedAction: AutoActionSettings? = nil + ) { + self.allActionsIncluded = allActionsIncluded + self.templates = templates + self.autoCreatedAction = autoCreatedAction + self.autoOpenedAction = autoOpenedAction + self.autoPlacedAction = autoPlacedAction + } +} + +/// A template for an action assertion. +public struct ActionTemplateSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case action + case softwareAgent = "software_agent" + case softwareAgentIndex = "software_agent_index" + case sourceType = "source_type" + case description + } + + /// The action identifier. + public var action: String + + /// Software agent information for this action. + public var softwareAgent: ClaimGeneratorInfoSettings? + + /// Index of the software agent in the claim generator info list. + public var softwareAgentIndex: Int? + + /// The digital source type for this action. + public var sourceType: String? + + /// A human-readable description of the action. + public var description: String? + + public init( + action: String, + softwareAgent: ClaimGeneratorInfoSettings? = nil, + softwareAgentIndex: Int? = nil, + sourceType: String? = nil, + description: String? = nil + ) { + self.action = action + self.softwareAgent = softwareAgent + self.softwareAgentIndex = softwareAgentIndex + self.sourceType = sourceType + self.description = description + } +} + +/// Settings for automatic action generation. +public struct AutoActionSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case enabled + case sourceType = "source_type" + } + + /// Whether the automatic action is enabled. + public var enabled: Bool + + /// The digital source type for the automatic action. + public var sourceType: String? + + public init(enabled: Bool, sourceType: String? = nil) { + self.enabled = enabled + self.sourceType = sourceType + } +} + +// MARK: - Timestamp Settings + +/// Timestamp assertion configuration. +public struct TimeStampSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case enabled + case skipExisting = "skip_existing" + case fetchScope = "fetch_scope" + } + + /// Whether automatic timestamping is enabled. + public var enabled: Bool? + + /// Whether to skip timestamping if a timestamp already exists. + public var skipExisting: Bool? + + /// The scope for fetching timestamps. + public var fetchScope: TimeStampFetchScope? + + public init( + enabled: Bool? = nil, + skipExisting: Bool? = nil, + fetchScope: TimeStampFetchScope? = nil + ) { + self.enabled = enabled + self.skipExisting = skipExisting + self.fetchScope = fetchScope + } +} + +/// Scope for fetching timestamps. +public enum TimeStampFetchScope: String, Codable, Sendable { + case parent + case all +} + +/// Scope for fetching OCSP certificate status. +public enum OcspFetchScope: String, Codable, Sendable { + case all + case active +} + +// MARK: - Settings Intent + +/// The intent for building manifests, specified in settings. +/// +/// - SeeAlso: ``BuilderIntent`` +public enum SettingsIntent: Codable, Sendable, Equatable { + /// A new digital creation with the specified digital source type URI. + case create(String) + + /// An edit of a pre-existing parent asset. + case edit + + /// A restricted version of edit for non-editorial changes. + case update + + private enum CodingKeys: String, CodingKey { + case create = "Create" + } + + private struct CreatePayload: Codable, Sendable { + enum CodingKeys: String, CodingKey { + case digitalSourceType = "digital_source_type" + } + let digitalSourceType: String + } + + public init(from decoder: Decoder) throws { + // Try as a plain string first ("Edit" or "Update") + if let container = try? decoder.singleValueContainer(), + let stringValue = try? container.decode(String.self) { + switch stringValue { + case "Edit": + self = .edit + return + case "Update": + self = .update + return + default: + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown intent string: \(stringValue)") + } + } + + // Try as an object {"Create": {"digital_source_type": "..."}} + let container = try decoder.container(keyedBy: CodingKeys.self) + let payload = try container.decode(CreatePayload.self, forKey: .create) + self = .create(payload.digitalSourceType) + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .edit: + var container = encoder.singleValueContainer() + try container.encode("Edit") + case .update: + var container = encoder.singleValueContainer() + try container.encode("Update") + case .create(let digitalSourceType): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(CreatePayload(digitalSourceType: digitalSourceType), forKey: .create) + } + } +} + +// MARK: - Signer Settings + +/// Signer configuration, either local or remote. +public enum SignerSettings: Codable, Sendable, Equatable { + /// A local signer with credentials stored on-device. + case local(LocalSignerSettings) + + /// A remote signer that delegates to a signing service. + case remote(RemoteSignerSettings) + + private enum CodingKeys: String, CodingKey { + case local + case remote + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let local = try container.decodeIfPresent(LocalSignerSettings.self, forKey: .local) { + self = .local(local) + } else if let remote = try container.decodeIfPresent(RemoteSignerSettings.self, forKey: .remote) { + self = .remote(remote) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "SignerSettings must contain either 'local' or 'remote'")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .local(let settings): + try container.encode(settings, forKey: .local) + case .remote(let settings): + try container.encode(settings, forKey: .remote) + } + } +} + +/// Local signer credentials and configuration. +public struct LocalSignerSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case alg + case signCert = "sign_cert" + case privateKey = "private_key" + case tsaUrl = "tsa_url" + case referencedAssertions = "referenced_assertions" + case roles + } + + /// The signing algorithm identifier (e.g., "es256"). + public var alg: String + + /// The certificate chain in PEM format. + public var signCert: String + + /// The private key in PEM format. + public var privateKey: String + + /// Optional URL of a timestamp authority. + public var tsaUrl: String? + + /// Assertion labels referenced by the signer. + public var referencedAssertions: [String]? + + /// Signer roles. + public var roles: [String]? + + public init( + alg: String, + signCert: String, + privateKey: String, + tsaUrl: String? = nil, + referencedAssertions: [String]? = nil, + roles: [String]? = nil + ) { + self.alg = alg + self.signCert = signCert + self.privateKey = privateKey + self.tsaUrl = tsaUrl + self.referencedAssertions = referencedAssertions + self.roles = roles + } +} + +/// Remote signer configuration. +public struct RemoteSignerSettings: Codable, Sendable, Equatable { + + public enum CodingKeys: String, CodingKey { + case url + case alg + case signCert = "sign_cert" + case tsaUrl = "tsa_url" + case referencedAssertions = "referenced_assertions" + case roles + } + + /// The URL of the remote signing service. + public var url: String + + /// The signing algorithm identifier. + public var alg: String + + /// The certificate chain in PEM format. + public var signCert: String + + /// Optional URL of a timestamp authority. + public var tsaUrl: String? + + /// Assertion labels referenced by the signer. + public var referencedAssertions: [String]? + + /// Signer roles. + public var roles: [String]? + + public init( + url: String, + alg: String, + signCert: String, + tsaUrl: String? = nil, + referencedAssertions: [String]? = nil, + roles: [String]? = nil + ) { + self.url = url + self.alg = alg + self.signCert = signCert + self.tsaUrl = tsaUrl + self.referencedAssertions = referencedAssertions + self.roles = roles + } +} diff --git a/Library/Tests/LibraryTestRunner.swift b/Library/Tests/LibraryTestRunner.swift index c199b36..81680f4 100644 --- a/Library/Tests/LibraryTestRunner.swift +++ b/Library/Tests/LibraryTestRunner.swift @@ -759,3 +759,74 @@ final class AssertionDefinitionTests: XCTestCase { XCTAssertTrue(result.passed, result.message) } } + +// MARK: - Settings Definition Tests + +final class SettingsDefinitionTests: XCTestCase { + private let tests = TestShared.SettingsDefinitionTests() + + func testRoundTrip() throws { + XCTAssertTrue(tests.testRoundTrip().passed) + } + func testFromJSON() throws { + XCTAssertTrue(tests.testFromJSON().passed) + } + func testPartialSettings() throws { + XCTAssertTrue(tests.testPartialSettings().passed) + } + func testSignerLocalSerialization() throws { + XCTAssertTrue(tests.testSignerLocalSerialization().passed) + } + func testSignerRemoteSerialization() throws { + XCTAssertTrue(tests.testSignerRemoteSerialization().passed) + } + func testIntentSerialization() throws { + XCTAssertTrue(tests.testIntentSerialization().passed) + } + func testEnumValues() throws { + XCTAssertTrue(tests.testEnumValues().passed) + } + func testExistingSettingsJSON() throws { + XCTAssertTrue(tests.testExistingSettingsJSON().passed) + } + func testPrettyJSON() throws { + XCTAssertTrue(tests.testPrettyJSON().passed) + } + func testTrustSettings() throws { + XCTAssertTrue(tests.testTrustSettings().passed) + } + func testCoreSettings() throws { + XCTAssertTrue(tests.testCoreSettings().passed) + } + func testVerifySettings() throws { + XCTAssertTrue(tests.testVerifySettings().passed) + } + func testBuilderSettings() throws { + XCTAssertTrue(tests.testBuilderSettings().passed) + } + func testFullDefinitionRoundTrip() throws { + XCTAssertTrue(tests.testFullDefinitionRoundTrip().passed) + } + func testC2PASettingsFromDefinition() throws { + XCTAssertTrue(tests.testC2PASettingsFromDefinition().passed) + } + func testC2PASettingsLoadDefinition() throws { + XCTAssertTrue(tests.testC2PASettingsLoadDefinition().passed) + } + func testC2PASettingsSetValue() throws { + XCTAssertTrue(tests.testC2PASettingsSetValue().passed) + } + func testC2PASettingsSetValueErrors() throws { + XCTAssertTrue(tests.testC2PASettingsSetValueErrors().passed) + } + func testSignerWithRoles() throws { + XCTAssertTrue(tests.testSignerWithRoles().passed) + } + func testActionTemplateWithIndex() throws { + XCTAssertTrue(tests.testActionTemplateWithIndex().passed) + } + func testTimestampParentScope() throws { + XCTAssertTrue(tests.testTimestampParentScope().passed) + } +} + diff --git a/TestShared/Sources/SettingsDefinitionTests.swift b/TestShared/Sources/SettingsDefinitionTests.swift new file mode 100644 index 0000000..d8ad1f9 --- /dev/null +++ b/TestShared/Sources/SettingsDefinitionTests.swift @@ -0,0 +1,688 @@ +// 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. + +import C2PA +import Foundation + +public final class SettingsDefinitionTests: TestImplementation { + + public init() {} + + public func testRoundTrip() -> TestResult { + let definition = C2PASettingsDefinition( + version: 1, + verify: VerifySettings( + verifyAfterReading: true, + verifyTrust: false + ), + builder: BuilderSettingsDefinition( + vendor: "com.example", + thumbnail: ThumbnailSettings( + format: .jpeg, + quality: .medium + ), + intent: .edit + ), + signer: .local(LocalSignerSettings( + alg: "es256", + signCert: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + tsaUrl: "http://timestamp.example.com" + )) + ) + + do { + let json = try definition.toJSON() + let decoded = try C2PASettingsDefinition.fromJSON(json) + + guard decoded == definition else { + return .failure("Round Trip", "Decoded definition does not match original") + } + + return .success("Round Trip", "[PASS] Settings definition round-trips through JSON") + } catch { + return .failure("Round Trip", "Failed: \(error)") + } + } + + public func testFromJSON() -> TestResult { + let json = """ + { + "version": 1, + "verify": { + "verify_after_reading": true, + "ocsp_fetch": false + }, + "builder": { + "vendor": "test-vendor", + "thumbnail": { + "format": "png", + "quality": "high", + "long_edge": 512 + } + } + } + """ + + do { + let definition = try C2PASettingsDefinition.fromJSON(json) + + guard definition.version == 1 else { + return .failure("From JSON", "Expected version 1, got \(String(describing: definition.version))") + } + guard definition.verify?.verifyAfterReading == true else { + return .failure("From JSON", "Expected verifyAfterReading true") + } + guard definition.verify?.ocspFetch == false else { + return .failure("From JSON", "Expected ocspFetch false") + } + guard definition.builder?.vendor == "test-vendor" else { + return .failure("From JSON", "Expected vendor 'test-vendor'") + } + guard definition.builder?.thumbnail?.format == .png else { + return .failure("From JSON", "Expected thumbnail format png") + } + guard definition.builder?.thumbnail?.quality == .high else { + return .failure("From JSON", "Expected thumbnail quality high") + } + guard definition.builder?.thumbnail?.longEdge == 512 else { + return .failure("From JSON", "Expected thumbnail longEdge 512") + } + + return .success("From JSON", "[PASS] Settings definition decoded from JSON correctly") + } catch { + return .failure("From JSON", "Failed: \(error)") + } + } + + public func testPartialSettings() -> TestResult { + let definition = C2PASettingsDefinition(version: 1) + + do { + let json = try definition.toJSON() + let decoded = try C2PASettingsDefinition.fromJSON(json) + + guard decoded.version == 1 else { + return .failure("Partial Settings", "Expected version 1") + } + guard decoded.trust == nil else { + return .failure("Partial Settings", "Expected nil trust") + } + guard decoded.signer == nil else { + return .failure("Partial Settings", "Expected nil signer") + } + guard decoded.builder == nil else { + return .failure("Partial Settings", "Expected nil builder") + } + + return .success("Partial Settings", "[PASS] Partial settings encode/decode correctly") + } catch { + return .failure("Partial Settings", "Failed: \(error)") + } + } + + public func testSignerLocalSerialization() -> TestResult { + let signer = SignerSettings.local(LocalSignerSettings( + alg: "es256", + signCert: "cert", + privateKey: "key", + tsaUrl: "http://tsa.example.com", + referencedAssertions: ["cawg.training-mining"] + )) + + do { + let json = try C2PAJson.encode(signer) + let decoded = try C2PAJson.decode(SignerSettings.self, from: json) + + guard decoded == signer else { + return .failure("Signer Local", "Decoded signer does not match original") + } + + guard case .local(let local) = decoded else { + return .failure("Signer Local", "Expected local signer") + } + guard local.alg == "es256" else { + return .failure("Signer Local", "Expected alg es256") + } + guard local.referencedAssertions == ["cawg.training-mining"] else { + return .failure("Signer Local", "Expected referenced assertions") + } + + return .success("Signer Local", "[PASS] Local signer settings serialize correctly") + } catch { + return .failure("Signer Local", "Failed: \(error)") + } + } + + public func testSignerRemoteSerialization() -> TestResult { + let signer = SignerSettings.remote(RemoteSignerSettings( + url: "https://signing.example.com", + alg: "es384", + signCert: "cert" + )) + + do { + let json = try C2PAJson.encode(signer) + let decoded = try C2PAJson.decode(SignerSettings.self, from: json) + + guard case .remote(let remote) = decoded else { + return .failure("Signer Remote", "Expected remote signer") + } + guard remote.url == "https://signing.example.com" else { + return .failure("Signer Remote", "Expected remote URL") + } + guard remote.alg == "es384" else { + return .failure("Signer Remote", "Expected alg es384") + } + + return .success("Signer Remote", "[PASS] Remote signer settings serialize correctly") + } catch { + return .failure("Signer Remote", "Failed: \(error)") + } + } + + public func testIntentSerialization() -> TestResult { + do { + // Test edit + let editJSON = try C2PAJson.encode(SettingsIntent.edit) + let decodedEdit = try C2PAJson.decode(SettingsIntent.self, from: editJSON) + guard decodedEdit == .edit else { + return .failure("Intent Serialization", "Edit intent round-trip failed") + } + + // Test update + let updateJSON = try C2PAJson.encode(SettingsIntent.update) + let decodedUpdate = try C2PAJson.decode(SettingsIntent.self, from: updateJSON) + guard decodedUpdate == .update else { + return .failure("Intent Serialization", "Update intent round-trip failed") + } + + // Test create + let createIntent = SettingsIntent.create("http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture") + let createJSON = try C2PAJson.encode(createIntent) + let decodedCreate = try C2PAJson.decode(SettingsIntent.self, from: createJSON) + guard decodedCreate == createIntent else { + return .failure("Intent Serialization", "Create intent round-trip failed") + } + + return .success("Intent Serialization", "[PASS] All intent variants serialize correctly") + } catch { + return .failure("Intent Serialization", "Failed: \(error)") + } + } + + public func testEnumValues() -> TestResult { + // ThumbnailFormat + let formats: [(ThumbnailFormat, String)] = [ + (.png, "png"), (.jpeg, "jpeg"), (.gif, "gif"), (.webp, "webp"), (.tiff, "tiff") + ] + for (format, expected) in formats { + guard format.rawValue == expected else { + return .failure("Enum Values", "ThumbnailFormat.\(format) expected rawValue '\(expected)', got '\(format.rawValue)'") + } + } + + // ThumbnailQuality + let qualities: [(ThumbnailQuality, String)] = [ + (.low, "low"), (.medium, "medium"), (.high, "high") + ] + for (quality, expected) in qualities { + guard quality.rawValue == expected else { + return .failure("Enum Values", "ThumbnailQuality.\(quality) expected rawValue '\(expected)', got '\(quality.rawValue)'") + } + } + + // OcspFetchScope + guard OcspFetchScope.all.rawValue == "all" else { + return .failure("Enum Values", "OcspFetchScope.all rawValue mismatch") + } + guard OcspFetchScope.active.rawValue == "active" else { + return .failure("Enum Values", "OcspFetchScope.active rawValue mismatch") + } + + // TimeStampFetchScope + guard TimeStampFetchScope.parent.rawValue == "parent" else { + return .failure("Enum Values", "TimeStampFetchScope.parent rawValue mismatch") + } + guard TimeStampFetchScope.all.rawValue == "all" else { + return .failure("Enum Values", "TimeStampFetchScope.all rawValue mismatch") + } + + return .success("Enum Values", "[PASS] All enum rawValues match expected strings") + } + + public func testExistingSettingsJSON() -> TestResult { + guard let data = TestUtilities.loadTestResource(name: "test_settings_with_cawg_signing", ext: "json"), + let json = String(data: data, encoding: .utf8) else { + return .failure("Existing JSON", "Could not load test_settings_with_cawg_signing.json") + } + + do { + let definition = try C2PASettingsDefinition.fromJSON(json) + + guard definition.version == 1 else { + return .failure("Existing JSON", "Expected version 1") + } + + guard case .local(let signer) = definition.signer else { + return .failure("Existing JSON", "Expected local signer") + } + guard signer.alg == "es256" else { + return .failure("Existing JSON", "Expected signer alg es256") + } + guard signer.tsaUrl == "http://timestamp.digicert.com" else { + return .failure("Existing JSON", "Expected signer tsa_url") + } + + guard case .local(let cawg) = definition.cawgX509Signer else { + return .failure("Existing JSON", "Expected local cawg signer") + } + guard cawg.referencedAssertions == ["cawg.training-mining"] else { + return .failure("Existing JSON", "Expected cawg referenced assertions") + } + + return .success("Existing JSON", "[PASS] Existing test settings JSON decoded correctly") + } catch { + return .failure("Existing JSON", "Failed: \(error)") + } + } + + public func testPrettyJSON() -> TestResult { + let definition = C2PASettingsDefinition( + version: 1, + core: CoreSettings(merkleTreeChunkSizeInKb: 64) + ) + + do { + let pretty = try definition.toPrettyJSON() + let compact = try definition.toJSON() + + guard pretty.count > compact.count else { + return .failure("Pretty JSON", "Pretty JSON should be longer than compact") + } + guard pretty.contains("\n") else { + return .failure("Pretty JSON", "Pretty JSON should contain newlines") + } + + let decoded = try C2PASettingsDefinition.fromJSON(pretty) + guard decoded == definition else { + return .failure("Pretty JSON", "Pretty JSON should decode back to original") + } + + return .success("Pretty JSON", "[PASS] toPrettyJSON produces formatted, decodable JSON") + } catch { + return .failure("Pretty JSON", "Failed: \(error)") + } + } + + public func testTrustSettings() -> TestResult { + let trust = TrustSettings( + verifyTrustList: true, + userAnchors: "user-anchor-pem", + trustAnchors: "trust-anchor-pem", + trustConfig: "{\"trust\": true}", + allowedList: "allowed-list" + ) + + do { + let json = try C2PAJson.encode(trust) + let decoded = try C2PAJson.decode(TrustSettings.self, from: json) + guard decoded == trust else { + return .failure("Trust Settings", "Round trip failed") + } + + return .success("Trust Settings", "[PASS] TrustSettings round-trips correctly") + } catch { + return .failure("Trust Settings", "Failed: \(error)") + } + } + + public func testCoreSettings() -> TestResult { + let core = CoreSettings( + merkleTreeChunkSizeInKb: 64, + merkleTreeMaxProofs: 100, + backingStoreMemoryThresholdInMb: 256, + decodeIdentityAssertions: true, + allowedNetworkHosts: ["example.com", "cdn.example.com"] + ) + + do { + let json = try C2PAJson.encode(core) + let decoded = try C2PAJson.decode(CoreSettings.self, from: json) + guard decoded == core else { + return .failure("Core Settings", "Round trip failed") + } + + return .success("Core Settings", "[PASS] CoreSettings round-trips correctly") + } catch { + return .failure("Core Settings", "Failed: \(error)") + } + } + + public func testVerifySettings() -> TestResult { + let verify = VerifySettings( + verifyAfterReading: true, + verifyAfterSign: false, + verifyTrust: true, + verifyTimestampTrust: false, + ocspFetch: true, + remoteManifestFetch: false, + skipIngredientConflictResolution: true, + strictV1Validation: false + ) + + do { + let json = try C2PAJson.encode(verify) + let decoded = try C2PAJson.decode(VerifySettings.self, from: json) + guard decoded == verify else { + return .failure("Verify Settings", "Round trip failed") + } + + return .success("Verify Settings", "[PASS] VerifySettings round-trips correctly") + } catch { + return .failure("Verify Settings", "Failed: \(error)") + } + } + + public func testBuilderSettings() -> TestResult { + let builder = BuilderSettingsDefinition( + vendor: "com.test", + claimGeneratorInfo: ClaimGeneratorInfoSettings( + name: "TestApp", + version: "1.0", + operatingSystem: "iOS 17" + ), + thumbnail: ThumbnailSettings( + enabled: true, + ignoreErrors: false, + longEdge: 1024, + format: .webp, + preferSmallestFormat: true, + quality: .low + ), + actions: ActionsSettings( + allActionsIncluded: true, + templates: [ + ActionTemplateSettings( + action: "c2pa.created", + softwareAgent: ClaimGeneratorInfoSettings(name: "TestAgent"), + sourceType: "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture", + description: "Test action" + ) + ], + autoCreatedAction: AutoActionSettings(enabled: true, sourceType: "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"), + autoOpenedAction: AutoActionSettings(enabled: false), + autoPlacedAction: AutoActionSettings(enabled: true) + ), + certificateStatusFetch: .all, + certificateStatusShouldOverride: false, + intent: .create("http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"), + createdAssertionLabels: ["c2pa.actions"], + preferBoxHash: true, + generateC2paArchive: false, + autoTimestampAssertion: TimeStampSettings( + enabled: true, + skipExisting: false, + fetchScope: .all + ) + ) + + do { + let json = try C2PAJson.encode(builder) + let decoded = try C2PAJson.decode(BuilderSettingsDefinition.self, from: json) + guard decoded == builder else { + return .failure("Builder Settings", "Round trip failed") + } + guard decoded.claimGeneratorInfo?.name == "TestApp" else { + return .failure("Builder Settings", "ClaimGeneratorInfo name mismatch") + } + guard decoded.actions?.templates?.count == 1 else { + return .failure("Builder Settings", "Expected 1 action template") + } + guard decoded.actions?.autoCreatedAction?.enabled == true else { + return .failure("Builder Settings", "autoCreatedAction should be enabled") + } + guard decoded.autoTimestampAssertion?.fetchScope == .all else { + return .failure("Builder Settings", "fetchScope should be .all") + } + + return .success("Builder Settings", "[PASS] BuilderSettingsDefinition round-trips correctly") + } catch { + return .failure("Builder Settings", "Failed: \(error)") + } + } + + public func testFullDefinitionRoundTrip() -> TestResult { + let definition = C2PASettingsDefinition( + version: 1, + trust: TrustSettings(verifyTrustList: true), + cawgTrust: TrustSettings(verifyTrustList: false, userAnchors: "cawg-anchors"), + core: CoreSettings(merkleTreeChunkSizeInKb: 32, decodeIdentityAssertions: true), + verify: VerifySettings(verifyAfterReading: true, strictV1Validation: false), + builder: BuilderSettingsDefinition( + vendor: "full-test", + intent: .update + ), + signer: .local(LocalSignerSettings( + alg: "ps256", + signCert: "cert-pem", + privateKey: "key-pem", + roles: ["signer", "validator"] + )), + cawgX509Signer: .remote(RemoteSignerSettings( + url: "https://cawg.example.com", + alg: "es256", + signCert: "cawg-cert" + )) + ) + + do { + let json = try definition.toJSON() + let decoded = try C2PASettingsDefinition.fromJSON(json) + guard decoded == definition else { + return .failure("Full Definition", "Full round trip failed") + } + + guard case .local(let signer) = decoded.signer else { + return .failure("Full Definition", "Expected local signer") + } + guard signer.roles == ["signer", "validator"] else { + return .failure("Full Definition", "Signer roles mismatch") + } + + guard case .remote(let cawg) = decoded.cawgX509Signer else { + return .failure("Full Definition", "Expected remote cawg signer") + } + guard cawg.url == "https://cawg.example.com" else { + return .failure("Full Definition", "CAWG URL mismatch") + } + + guard decoded.cawgTrust?.userAnchors == "cawg-anchors" else { + return .failure("Full Definition", "cawgTrust userAnchors mismatch") + } + + return .success("Full Definition", "[PASS] Full definition with all sections round-trips correctly") + } catch { + return .failure("Full Definition", "Failed: \(error)") + } + } + + public func testC2PASettingsFromDefinition() -> TestResult { + let definition = C2PASettingsDefinition(version: 1) + + do { + let settings = try C2PASettings(definition: definition) + // If we get here, the settings loaded successfully into the C API + _ = settings + return .success("Settings From Definition", "[PASS] C2PASettings created from definition") + } catch { + return .failure("Settings From Definition", "Failed: \(error)") + } + } + + public func testC2PASettingsLoadDefinition() -> TestResult { + do { + let settings = try C2PASettings(json: "{\"version\": 1}") + let definition = C2PASettingsDefinition( + version: 1, + verify: VerifySettings(verifyAfterReading: true) + ) + try settings.load(definition: definition) + return .success("Settings Load Definition", "[PASS] C2PASettings.load(definition:) succeeded") + } catch { + return .failure("Settings Load Definition", "Failed: \(error)") + } + } + + public func testC2PASettingsSetValue() -> TestResult { + do { + let settings = try C2PASettings(json: "{\"version\": 1}") + + // Set a value the C API accepts + try settings.setValue(true, forPath: "verify.verify_after_reading") + + return .success("Settings SetValue", "[PASS] setValue works for nested paths") + } catch { + return .failure("Settings SetValue", "Failed: \(error)") + } + } + + public func testC2PASettingsSetValueErrors() -> TestResult { + // Test empty path + do { + let settings = try C2PASettings(json: "{\"version\": 1}") + try settings.setValue("value", forPath: "") + return .failure("Settings SetValue Errors", "Should have thrown for empty path") + } catch { + // Expected + } + + return .success("Settings SetValue Errors", "[PASS] setValue throws for invalid inputs") + } + + public func testSignerWithRoles() -> TestResult { + let local = LocalSignerSettings( + alg: "es256", + signCert: "cert", + privateKey: "key", + tsaUrl: nil, + referencedAssertions: nil, + roles: ["signer"] + ) + + let remote = RemoteSignerSettings( + url: "https://example.com", + alg: "es384", + signCert: "cert", + tsaUrl: "http://tsa.example.com", + referencedAssertions: ["assertion1"], + roles: ["validator"] + ) + + do { + let localJSON = try C2PAJson.encode(local) + let decodedLocal = try C2PAJson.decode(LocalSignerSettings.self, from: localJSON) + guard decodedLocal.roles == ["signer"] else { + return .failure("Signer Roles", "Local roles mismatch") + } + guard decodedLocal.tsaUrl == nil else { + return .failure("Signer Roles", "Local tsaUrl should be nil") + } + + let remoteJSON = try C2PAJson.encode(remote) + let decodedRemote = try C2PAJson.decode(RemoteSignerSettings.self, from: remoteJSON) + guard decodedRemote.roles == ["validator"] else { + return .failure("Signer Roles", "Remote roles mismatch") + } + guard decodedRemote.tsaUrl == "http://tsa.example.com" else { + return .failure("Signer Roles", "Remote tsaUrl mismatch") + } + + return .success("Signer Roles", "[PASS] Signer settings with all optional fields round-trip correctly") + } catch { + return .failure("Signer Roles", "Failed: \(error)") + } + } + + public func testActionTemplateWithIndex() -> TestResult { + let template = ActionTemplateSettings( + action: "c2pa.edited", + softwareAgentIndex: 0, + description: "Edited with app" + ) + + do { + let json = try C2PAJson.encode(template) + let decoded = try C2PAJson.decode(ActionTemplateSettings.self, from: json) + guard decoded == template else { + return .failure("Action Template", "Round trip failed") + } + guard decoded.softwareAgentIndex == 0 else { + return .failure("Action Template", "softwareAgentIndex mismatch") + } + guard decoded.softwareAgent == nil else { + return .failure("Action Template", "softwareAgent should be nil") + } + + return .success("Action Template", "[PASS] ActionTemplateSettings round-trips correctly") + } catch { + return .failure("Action Template", "Failed: \(error)") + } + } + + public func testTimestampParentScope() -> TestResult { + let timestamp = TimeStampSettings( + enabled: true, + skipExisting: true, + fetchScope: .parent + ) + + do { + let json = try C2PAJson.encode(timestamp) + let decoded = try C2PAJson.decode(TimeStampSettings.self, from: json) + guard decoded == timestamp else { + return .failure("Timestamp Parent", "Round trip failed") + } + guard decoded.fetchScope == .parent else { + return .failure("Timestamp Parent", "fetchScope should be .parent") + } + + return .success("Timestamp Parent", "[PASS] TimeStampSettings with parent scope round-trips") + } catch { + return .failure("Timestamp Parent", "Failed: \(error)") + } + } + + public func runAllTests() async -> [TestResult] { + return [ + testRoundTrip(), + testFromJSON(), + testPartialSettings(), + testSignerLocalSerialization(), + testSignerRemoteSerialization(), + testIntentSerialization(), + testEnumValues(), + testExistingSettingsJSON(), + testPrettyJSON(), + testTrustSettings(), + testCoreSettings(), + testVerifySettings(), + testBuilderSettings(), + testFullDefinitionRoundTrip(), + testC2PASettingsFromDefinition(), + testC2PASettingsLoadDefinition(), + testC2PASettingsSetValue(), + testC2PASettingsSetValueErrors(), + testSignerWithRoles(), + testActionTemplateWithIndex(), + testTimestampParentScope() + ] + } +} diff --git a/TestShared/TestShared.xcodeproj/project.pbxproj b/TestShared/TestShared.xcodeproj/project.pbxproj index db3703c..58cbddc 100644 --- a/TestShared/TestShared.xcodeproj/project.pbxproj +++ b/TestShared/TestShared.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 95COV0092E9700000000005 /* SignerExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95COV00A2E9700000000005 /* SignerExtendedTests.swift */; }; 95COV00B2E9700000000006 /* WebServiceSignerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95COV00C2E9700000000006 /* WebServiceSignerTests.swift */; }; 95COV00D2E9700000000007 /* AssertionDefinitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95COV00E2E9700000000007 /* AssertionDefinitionTests.swift */; }; + 95COV0102E9700000000008 /* SettingsDefinitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95COV0112E9700000000008 /* SettingsDefinitionTests.swift */; }; + 95C99A3C2E68D83F00C54D0E /* C2PA.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95C99A3B2E68D83F00C54D0E /* C2PA.framework */; }; 95C99A552E695DF100C54D0E /* StreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C99A542E695DF100C54D0E /* StreamTests.swift */; }; @@ -74,6 +76,7 @@ 95CAWG042E6EDDF700F0BC7C /* test_settings_with_cawg_signing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = test_settings_with_cawg_signing.json; sourceTree = ""; }; 95F271292E72CD5800A6A88D /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; A0B3CF4E2E96AC2C00F2F938 /* ManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestTests.swift; sourceTree = ""; }; + 95COV0112E9700000000008 /* SettingsDefinitionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDefinitionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -120,6 +123,7 @@ A0B3CF4E2E96AC2C00F2F938 /* ManifestTests.swift */, 95C99A522E695DF100C54D0E /* ReaderTests.swift */, 95COV0062E9700000000003 /* SecureEnclaveSignerTests.swift */, + 95COV0112E9700000000008 /* SettingsDefinitionTests.swift */, 95COV00A2E9700000000005 /* SignerExtendedTests.swift */, 95C99A532E695DF100C54D0E /* SigningTests.swift */, 95C99A542E695DF100C54D0E /* StreamTests.swift */, @@ -257,6 +261,7 @@ A0B3CF4F2E96AC2C00F2F938 /* ManifestTests.swift in Sources */, 95C99A592E695DF100C54D0E /* ReaderTests.swift in Sources */, 95COV0052E9700000000003 /* SecureEnclaveSignerTests.swift in Sources */, + 95COV0102E9700000000008 /* SettingsDefinitionTests.swift in Sources */, 95COV0092E9700000000005 /* SignerExtendedTests.swift in Sources */, 95C99A5A2E695DF100C54D0E /* SigningTests.swift in Sources */, 95C99A552E695DF100C54D0E /* StreamTests.swift in Sources */,