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
153 changes: 153 additions & 0 deletions .github/workflows/check-schema-changes.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF" >> $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 \
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

`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 }}
8 changes: 8 additions & 0 deletions Library/Library.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -59,6 +61,8 @@
A0C2A0012E96B00100F2F938 /* C2PAError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = C2PAError.swift; sourceTree = "<group>"; };
A0C2A0032E96B00100F2F938 /* C2PAJson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = C2PAJson.swift; sourceTree = "<group>"; };
A0C2A0052E96B00100F2F938 /* SignerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignerInfo.swift; sourceTree = "<group>"; };
A0C2A0072E96B00100F2F938 /* C2PASettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = C2PASettings.swift; sourceTree = "<group>"; };
A0C2A0092E96B00100F2F938 /* SettingsDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDefinition.swift; sourceTree = "<group>"; };
953415F12E6EB31400A97957 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
199 changes: 199 additions & 0 deletions Library/Sources/C2PASettings.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading