From 3e2acab323e3bc61e5e908ddb61141cd171de5ee Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Tue, 24 Feb 2026 19:34:36 +0100 Subject: [PATCH 1/2] Add context, settings, and JSON APIs Introduce C2PAContext and C2PASettings for shared configuration across readers and builders. Add C2PAJson for centralized JSON serialization. Add SettingsValidator for validating C2PA settings. Rewrite Builder to use context-based creation flow. Add Reader context support with withStream and withFragment. Update native library to v0.75.19. --- library/build.gradle.kts | 2 +- library/gradle.properties | 2 +- .../contentauth/c2pa/AndroidBuilderTests.kt | 51 +- .../c2pa/AndroidSettingsValidatorTests.kt | 148 +++ .../contentauth/c2pa/AndroidStreamTests.kt | 15 + library/src/main/jni/c2pa_jni.c | 264 +++- .../kotlin/org/contentauth/c2pa/Builder.kt | 296 ++++- .../org/contentauth/c2pa/C2PAContext.kt | 105 ++ .../kotlin/org/contentauth/c2pa/C2PAJson.kt | 42 + .../org/contentauth/c2pa/C2PASettings.kt | 109 ++ .../contentauth/c2pa/CertificateManager.kt | 19 +- .../kotlin/org/contentauth/c2pa/Reader.kt | 91 ++ .../kotlin/org/contentauth/c2pa/Stream.kt | 130 +- .../org/contentauth/c2pa/WebServiceSigner.kt | 15 +- .../c2pa/manifest/SettingsValidator.kt | 725 +++++++++++ .../c2pa/manifest/ValidationResult.kt | 37 + .../contentauth/c2pa/testapp/TestScreen.kt | 36 +- .../c2pa/test/shared/BuilderTests.kt | 896 ++++++++++---- .../test/shared/SettingsValidatorTests.kt | 1081 +++++++++++++++++ .../c2pa/test/shared/StreamTests.kt | 145 +++ .../contentauth/c2pa/test/shared/TestBase.kt | 27 +- 21 files changed, 3851 insertions(+), 385 deletions(-) create mode 100644 library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/SettingsValidator.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt create mode 100644 test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 0d246a3..46691df 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -36,10 +36,10 @@ android { // Specify ABIs to use prebuilt .so files ndk { - abiFilters.add("x86_64") abiFilters.add("arm64-v8a") abiFilters.add("armeabi-v7a") abiFilters.add("x86") + abiFilters.add("x86_64") } } diff --git a/library/gradle.properties b/library/gradle.properties index d2535b9..0fe5ca1 100644 --- a/library/gradle.properties +++ b/library/gradle.properties @@ -1,3 +1,3 @@ # C2PA Native Library Version # Update this to use a different release from https://github.com/contentauth/c2pa-rs/releases -c2paVersion=v0.75.8 +c2paVersion=v0.75.19 diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt index bcf8564..2f15dd9 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt @@ -1,4 +1,4 @@ -/* +/* 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. @@ -9,6 +9,7 @@ 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. */ + package org.contentauth.c2pa import android.content.Context @@ -85,4 +86,52 @@ class AndroidBuilderTests : BuilderTests() { val result = testJsonRoundTrip() assertTrue(result.success, "JSON Round-trip test failed: ${result.message}") } + + @Test + fun runTestBuilderFromContextWithSettings() = runBlocking { + val result = testBuilderFromContextWithSettings() + assertTrue(result.success, "Builder from Context with Settings test failed: ${result.message}") + } + + @Test + fun runTestBuilderFromJsonWithSettings() = runBlocking { + val result = testBuilderFromJsonWithSettings() + assertTrue(result.success, "Builder fromJson with Settings test failed: ${result.message}") + } + + @Test + fun runTestBuilderWithArchive() = runBlocking { + val result = testBuilderWithArchive() + assertTrue(result.success, "Builder withArchive test failed: ${result.message}") + } + + @Test + fun runTestReaderFromContext() = runBlocking { + val result = testReaderFromContext() + assertTrue(result.success, "Reader fromContext test failed: ${result.message}") + } + + @Test + fun runTestBuilderSetIntent() = runBlocking { + val result = testBuilderSetIntent() + assertTrue(result.success, "Builder Set Intent test failed: ${result.message}") + } + + @Test + fun runTestBuilderAddAction() = runBlocking { + val result = testBuilderAddAction() + assertTrue(result.success, "Builder Add Action test failed: ${result.message}") + } + + @Test + fun runTestSettingsSetValue() = runBlocking { + val result = testSettingsSetValue() + assertTrue(result.success, "C2PASettings setValue test failed: ${result.message}") + } + + @Test + fun runTestBuilderIntentEditAndUpdate() = runBlocking { + val result = testBuilderIntentEditAndUpdate() + assertTrue(result.success, "Builder Intent Edit and Update test failed: ${result.message}") + } } diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt new file mode 100644 index 0000000..548ea6c --- /dev/null +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt @@ -0,0 +1,148 @@ +/* +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. +*/ +package org.contentauth.c2pa + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.contentauth.c2pa.test.shared.SettingsValidatorTests +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.test.assertTrue + +/** Android instrumented tests for SettingsValidator. */ +@RunWith(AndroidJUnit4::class) +class AndroidSettingsValidatorTests : SettingsValidatorTests() { + + private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + override fun getContext(): Context = targetContext + + override fun loadResourceAsBytes(resourceName: String): ByteArray = + ResourceTestHelper.loadResourceAsBytes(resourceName) + + override fun loadResourceAsString(resourceName: String): String = + ResourceTestHelper.loadResourceAsString(resourceName) + + override fun copyResourceToFile(resourceName: String, fileName: String): File = + ResourceTestHelper.copyResourceToFile(targetContext, resourceName, fileName) + + @Test + fun runTestValidSettings() = runBlocking { + val result = testValidSettings() + assertTrue(result.success, "Valid Settings test failed: ${result.message}") + } + + @Test + fun runTestInvalidJson() = runBlocking { + val result = testInvalidJson() + assertTrue(result.success, "Invalid JSON test failed: ${result.message}") + } + + @Test + fun runTestMissingVersion() = runBlocking { + val result = testMissingVersion() + assertTrue(result.success, "Missing Version test failed: ${result.message}") + } + + @Test + fun runTestWrongVersion() = runBlocking { + val result = testWrongVersion() + assertTrue(result.success, "Wrong Version test failed: ${result.message}") + } + + @Test + fun runTestUnknownTopLevelKeys() = runBlocking { + val result = testUnknownTopLevelKeys() + assertTrue(result.success, "Unknown Top-Level Keys test failed: ${result.message}") + } + + @Test + fun runTestTrustSection() = runBlocking { + val result = testTrustSection() + assertTrue(result.success, "Trust Section test failed: ${result.message}") + } + + @Test + fun runTestCawgTrustSection() = runBlocking { + val result = testCawgTrustSection() + assertTrue(result.success, "CAWG Trust Section test failed: ${result.message}") + } + + @Test + fun runTestCoreSection() = runBlocking { + val result = testCoreSection() + assertTrue(result.success, "Core Section test failed: ${result.message}") + } + + @Test + fun runTestVerifySection() = runBlocking { + val result = testVerifySection() + assertTrue(result.success, "Verify Section test failed: ${result.message}") + } + + @Test + fun runTestBuilderSection() = runBlocking { + val result = testBuilderSection() + assertTrue(result.success, "Builder Section test failed: ${result.message}") + } + + @Test + fun runTestThumbnailSection() = runBlocking { + val result = testThumbnailSection() + assertTrue(result.success, "Thumbnail Section test failed: ${result.message}") + } + + @Test + fun runTestActionsSection() = runBlocking { + val result = testActionsSection() + assertTrue(result.success, "Actions Section test failed: ${result.message}") + } + + @Test + fun runTestLocalSigner() = runBlocking { + val result = testLocalSigner() + assertTrue(result.success, "Local Signer test failed: ${result.message}") + } + + @Test + fun runTestRemoteSigner() = runBlocking { + val result = testRemoteSigner() + assertTrue(result.success, "Remote Signer test failed: ${result.message}") + } + + @Test + fun runTestSignerMutualExclusion() = runBlocking { + val result = testSignerMutualExclusion() + assertTrue(result.success, "Signer Mutual Exclusion test failed: ${result.message}") + } + + @Test + fun runTestValidationResultHelpers() = runBlocking { + val result = testValidationResultHelpers() + assertTrue(result.success, "ValidationResult Helpers test failed: ${result.message}") + } + + @Test + fun runTestValidateAndLog() = runBlocking { + val result = testValidateAndLog() + assertTrue(result.success, "Validate and Log test failed: ${result.message}") + } + + @Test + fun runTestIntentAsNumber() = runBlocking { + val result = testIntentAsNumber() + assertTrue(result.success, "Intent As Number test failed: ${result.message}") + } +} diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt index e861636..001043e 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt @@ -62,6 +62,21 @@ class AndroidStreamTests : StreamTests() { assertTrue(result.success, "Custom Stream Callbacks test failed: ${result.message}") } + @Test + fun runTestCallbackStreamFactories() = runBlocking { + val result = testCallbackStreamFactories() + assertTrue(result.success, "Callback Stream Factories test failed: ${result.message}") + } + + @Test + fun runTestByteArrayStreamBufferGrowth() = runBlocking { + val result = testByteArrayStreamBufferGrowth() + assertTrue( + result.success, + "ByteArrayStream Buffer Growth test failed: ${result.message}", + ) + } + @Test fun runTestFileOperationsWithDataDirectory() = runBlocking { val result = testFileOperationsWithDataDirectory() diff --git a/library/src/main/jni/c2pa_jni.c b/library/src/main/jni/c2pa_jni.c index 1bb3252..fbe6a76 100644 --- a/library/src/main/jni/c2pa_jni.c +++ b/library/src/main/jni/c2pa_jni.c @@ -750,29 +750,6 @@ JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_resourceToStreamNative( } // Builder native methods -JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromJson(JNIEnv *env, jclass clazz, jstring manifestJson) { - if (manifestJson == NULL) { - (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), - "Manifest JSON cannot be null"); - return 0; - } - - const char *cmanifestJson = jstring_to_cstring(env, manifestJson); - if (cmanifestJson == NULL) { - return 0; - } - - struct C2paBuilder *builder = c2pa_builder_from_json(cmanifestJson); - release_cstring(env, manifestJson, cmanifestJson); - - if (builder == NULL) { - throw_c2pa_exception(env, "Failed to create builder from JSON"); - return 0; - } - - return (jlong)(uintptr_t)builder; -} - JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromArchive(JNIEnv *env, jclass clazz, jlong streamPtr) { if (streamPtr == 0) { (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), @@ -1216,6 +1193,247 @@ JNIEXPORT void JNICALL Java_org_contentauth_c2pa_Signer_free(JNIEnv *env, jobjec } } +// C2PASettings native methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PASettings_nativeNew(JNIEnv *env, jclass clazz) { + struct C2paSettings *settings = c2pa_settings_new(); + if (settings == NULL) { + return 0; + } + return (jlong)(uintptr_t)settings; +} + +JNIEXPORT jint JNICALL Java_org_contentauth_c2pa_C2PASettings_updateFromStringNative(JNIEnv *env, jobject obj, jlong settingsPtr, jstring settingsStr, jstring format) { + if (settingsPtr == 0 || settingsStr == NULL || format == NULL) { + return -1; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + const char *csettingsStr = jstring_to_cstring(env, settingsStr); + const char *cformat = jstring_to_cstring(env, format); + + if (csettingsStr == NULL || cformat == NULL) { + release_cstring(env, settingsStr, csettingsStr); + release_cstring(env, format, cformat); + return -1; + } + + int result = c2pa_settings_update_from_string(settings, csettingsStr, cformat); + + release_cstring(env, settingsStr, csettingsStr); + release_cstring(env, format, cformat); + + return result; +} + +JNIEXPORT jint JNICALL Java_org_contentauth_c2pa_C2PASettings_setValueNative(JNIEnv *env, jobject obj, jlong settingsPtr, jstring path, jstring value) { + if (settingsPtr == 0 || path == NULL || value == NULL) { + return -1; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + const char *cpath = jstring_to_cstring(env, path); + const char *cvalue = jstring_to_cstring(env, value); + + if (cpath == NULL || cvalue == NULL) { + release_cstring(env, path, cpath); + release_cstring(env, value, cvalue); + return -1; + } + + int result = c2pa_settings_set_value(settings, cpath, cvalue); + + release_cstring(env, path, cpath); + release_cstring(env, value, cvalue); + + return result; +} + +JNIEXPORT void JNICALL Java_org_contentauth_c2pa_C2PASettings_free(JNIEnv *env, jobject obj, jlong settingsPtr) { + if (settingsPtr != 0) { + c2pa_free((const void*)(uintptr_t)settingsPtr); + } +} + +// C2PAContext native methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PAContext_nativeNew(JNIEnv *env, jclass clazz) { + struct C2paContext *context = c2pa_context_new(); + if (context == NULL) { + return 0; + } + return (jlong)(uintptr_t)context; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PAContext_nativeNewWithSettings(JNIEnv *env, jclass clazz, jlong settingsPtr) { + if (settingsPtr == 0) { + return 0; + } + + struct C2paContextBuilder *builder = c2pa_context_builder_new(); + if (builder == NULL) { + return 0; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + int result = c2pa_context_builder_set_settings(builder, settings); + if (result < 0) { + c2pa_free(builder); + return 0; + } + + // build consumes the builder + struct C2paContext *context = c2pa_context_builder_build(builder); + if (context == NULL) { + return 0; + } + + return (jlong)(uintptr_t)context; +} + +JNIEXPORT void JNICALL Java_org_contentauth_c2pa_C2PAContext_free(JNIEnv *env, jobject obj, jlong contextPtr) { + if (contextPtr != 0) { + c2pa_free((const void*)(uintptr_t)contextPtr); + } +} + +// Builder context-based methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromContext(JNIEnv *env, jclass clazz, jlong contextPtr) { + if (contextPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Context cannot be null"); + return 0; + } + + struct C2paContext *context = (struct C2paContext*)(uintptr_t)contextPtr; + struct C2paBuilder *builder = c2pa_builder_from_context(context); + + if (builder == NULL) { + throw_c2pa_exception(env, "Failed to create builder from context"); + return 0; + } + + return (jlong)(uintptr_t)builder; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_withDefinitionNative(JNIEnv *env, jobject obj, jlong builderPtr, jstring manifestJson) { + if (builderPtr == 0 || manifestJson == NULL) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Builder and manifest JSON cannot be null"); + return 0; + } + + struct C2paBuilder *builder = (struct C2paBuilder*)(uintptr_t)builderPtr; + const char *cmanifestJson = jstring_to_cstring(env, manifestJson); + if (cmanifestJson == NULL) { + return 0; + } + + // This consumes the old builder pointer + struct C2paBuilder *newBuilder = c2pa_builder_with_definition(builder, cmanifestJson); + release_cstring(env, manifestJson, cmanifestJson); + + if (newBuilder == NULL) { + throw_c2pa_exception(env, "Failed to set builder definition"); + return 0; + } + + return (jlong)(uintptr_t)newBuilder; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_withArchiveNative(JNIEnv *env, jobject obj, jlong builderPtr, jlong streamPtr) { + if (builderPtr == 0 || streamPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Builder and stream cannot be null"); + return 0; + } + + struct C2paBuilder *builder = (struct C2paBuilder*)(uintptr_t)builderPtr; + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + + // This consumes the old builder pointer + struct C2paBuilder *newBuilder = c2pa_builder_with_archive(builder, stream); + + if (newBuilder == NULL) { + throw_c2pa_exception(env, "Failed to set builder archive"); + return 0; + } + + return (jlong)(uintptr_t)newBuilder; +} + +// Reader context-based methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_nativeFromContext(JNIEnv *env, jclass clazz, jlong contextPtr) { + if (contextPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Context cannot be null"); + return 0; + } + + struct C2paContext *context = (struct C2paContext*)(uintptr_t)contextPtr; + struct C2paReader *reader = c2pa_reader_from_context(context); + + if (reader == NULL) { + throw_c2pa_exception(env, "Failed to create reader from context"); + return 0; + } + + return (jlong)(uintptr_t)reader; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_withStreamNative(JNIEnv *env, jobject obj, jlong readerPtr, jstring format, jlong streamPtr) { + if (readerPtr == 0 || format == NULL || streamPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Reader, format, and stream cannot be null"); + return 0; + } + + struct C2paReader *reader = (struct C2paReader*)(uintptr_t)readerPtr; + const char *cformat = jstring_to_cstring(env, format); + if (cformat == NULL) { + return 0; + } + + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + + // This consumes the old reader pointer + struct C2paReader *newReader = c2pa_reader_with_stream(reader, cformat, stream); + release_cstring(env, format, cformat); + + if (newReader == NULL) { + throw_c2pa_exception(env, "Failed to configure reader with stream"); + return 0; + } + + return (jlong)(uintptr_t)newReader; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_withFragmentNative(JNIEnv *env, jobject obj, jlong readerPtr, jstring format, jlong streamPtr, jlong fragmentPtr) { + if (readerPtr == 0 || format == NULL || streamPtr == 0 || fragmentPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Reader, format, stream, and fragment cannot be null"); + return 0; + } + + struct C2paReader *reader = (struct C2paReader*)(uintptr_t)readerPtr; + const char *cformat = jstring_to_cstring(env, format); + if (cformat == NULL) { + return 0; + } + + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + struct C2paStream *fragment = (struct C2paStream*)(uintptr_t)fragmentPtr; + + // This consumes the old reader pointer + struct C2paReader *newReader = c2pa_reader_with_fragment(reader, cformat, stream, fragment); + release_cstring(env, format, cformat); + + if (newReader == NULL) { + throw_c2pa_exception(env, "Failed to configure reader with fragment"); + return 0; + } + + return (jlong)(uintptr_t)newReader; +} + // Ed25519 signing JNIEXPORT jbyteArray JNICALL Java_org_contentauth_c2pa_C2PA_ed25519SignNative(JNIEnv *env, jclass clazz, jbyteArray data, jstring privateKey) { if (data == NULL || privateKey == NULL) { diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt index e11f7fc..078d130 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt @@ -115,12 +115,29 @@ class Builder internal constructor(private var ptr: Long) : Closeable { loadC2PALibraries() } + /** + * Default assertion labels that should be placed in `created_assertions`. + * + * These are assertions that are typically generated by the signing application + * and should be attributed to the signer per the C2PA 2.3 specification. + * Override by passing a custom [C2PASettings] to [fromJson(String, C2PASettings)]. + */ + val DEFAULT_CREATED_ASSERTION_LABELS: List = listOf( + "c2pa.actions", + "c2pa.thumbnail.claim", + "c2pa.thumbnail.ingredient", + "c2pa.ingredient", + ) + /** * Creates a builder from a manifest definition in JSON format. * - * The JSON should contain the manifest structure including claims, assertions, and metadata - * according to the C2PA specification. This is useful for programmatically constructing - * manifests or loading manifest templates. + * This method automatically configures the SDK to place common assertions + * (actions, thumbnails, metadata) in `created_assertions` as intended by + * most applications. CAWG identity assertions are correctly placed in + * `gathered_assertions` per the CAWG specification. + * + * For full control over settings, use [fromJson(String, C2PASettings)]. * * @param manifestJSON The manifest definition as a JSON string * @return A Builder instance configured with the provided manifest @@ -142,12 +159,36 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * """ * val builder = Builder.fromJson(manifestJson) * ``` + * + * @see DEFAULT_CREATED_ASSERTION_LABELS + * @see fromJson(String, C2PASettings) */ @JvmStatic @Throws(C2PAError::class) - fun fromJson(manifestJSON: String): Builder = executeC2PAOperation("Failed to create builder from JSON") { - val handle = nativeFromJson(manifestJSON) - if (handle == 0L) null else Builder(handle) + fun fromJson(manifestJSON: String): Builder { + if (manifestJSON.isBlank()) { + throw C2PAError.Api("Manifest JSON must not be empty") + } + + val labelsArray = DEFAULT_CREATED_ASSERTION_LABELS.joinToString(", ") { "\"$it\"" } + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": [$labelsArray] + } + } + """.trimIndent() + + val settings = C2PASettings.create().apply { + updateFromString(settingsJson, "json") + } + val context = C2PAContext.fromSettings(settings) + settings.close() + + val builder = fromContext(context).withDefinition(manifestJSON) + context.close() + return builder } /** @@ -168,9 +209,123 @@ class Builder internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Builder(handle) } - @JvmStatic private external fun nativeFromJson(manifestJson: String): Long + /** + * Creates a builder from a shared [C2PAContext]. + * + * The context can be reused to create multiple builders and readers. + * The builder will inherit the context's settings. + * + * @param context The context to create the builder from + * @return A Builder instance configured with the context's settings + * @throws C2PAError.Api if the builder cannot be created + * + * @sample + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * val context = C2PAContext.fromSettings(settings) + * + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + * + * @see C2PAContext + * @see withDefinition + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromContext(context: C2PAContext): Builder = executeC2PAOperation("Failed to create builder from context") { + val handle = nativeFromContext(context.ptr) + if (handle == 0L) null else Builder(handle) + } + + /** + * Creates a builder from a manifest definition with custom settings. + * + * This gives full control over all SDK settings while also providing + * the manifest definition. The caller retains ownership of [settings] + * and may close it after this call returns. + * + * @param manifestJSON The manifest definition as a JSON string + * @param settings The settings to configure the builder with + * @return A Builder instance configured with the provided settings and manifest + * @throws C2PAError.Api if the JSON is invalid or settings cannot be applied + * + * @sample + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * val builder = Builder.fromJson(manifestJson, settings) + * settings.close() + * ``` + * + * @see C2PASettings + * @see fromJson + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromJson(manifestJSON: String, settings: C2PASettings): Builder { + if (manifestJSON.isBlank()) { + throw C2PAError.Api("Manifest JSON must not be empty") + } + + val context = C2PAContext.fromSettings(settings) + val builder = fromContext(context).withDefinition(manifestJSON) + context.close() + return builder + } @JvmStatic private external fun nativeFromArchive(streamHandle: Long): Long + + @JvmStatic private external fun nativeFromContext(contextPtr: Long): Long + } + + /** + * Updates the builder with a new manifest definition. + * + * @param manifestJSON The manifest definition as a JSON string + * @return This builder for fluent chaining + * @throws C2PAError.Api if the manifest JSON is invalid + * + * @sample + * ```kotlin + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + */ + @Throws(C2PAError::class) + fun withDefinition(manifestJSON: String): Builder { + val newPtr = withDefinitionNative(ptr, manifestJSON) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to set builder definition") + } + ptr = newPtr + return this + } + + /** + * Configures the builder with an archive stream. + * + * @param archive The input stream containing the C2PA archive + * @return This builder for fluent chaining + * @throws C2PAError.Api if the archive is invalid + * + * @sample + * ```kotlin + * val builder = Builder.fromContext(context) + * .withArchive(archiveStream) + * ``` + */ + @Throws(C2PAError::class) + fun withArchive(archive: Stream): Builder { + val newPtr = withArchiveNative(ptr, archive.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to set builder archive") + } + ptr = newPtr + return this } /** @@ -181,29 +336,26 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * ingredients are required. * * @param intent The [BuilderIntent] specifying the type of manifest + * @return This builder for fluent chaining * @throws C2PAError.Api if the intent cannot be set * * @sample * ```kotlin * val builder = Builder.fromJson(manifestJson) - * builder.setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) - * ``` - * - * @sample - * ```kotlin - * val builder = Builder.fromJson(manifestJson) - * builder.setIntent(BuilderIntent.Edit) + * .setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.CREATED)) * ``` * * @see BuilderIntent * @see DigitalSourceType */ @Throws(C2PAError::class) - fun setIntent(intent: BuilderIntent) { + fun setIntent(intent: BuilderIntent): Builder { val result = setIntentNative(ptr, intent.toNativeIntent(), intent.toNativeDigitalSourceType()) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to set intent") } + return this } /** @@ -214,57 +366,98 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * history. * * @param action The [Action] to add to the manifest + * @return This builder for fluent chaining * @throws C2PAError.Api if the action cannot be added * * @sample * ```kotlin * val builder = Builder.fromJson(manifestJson) - * builder.addAction(Action(PredefinedAction.EDITED, DigitalSourceType.DIGITAL_CAPTURE)) - * builder.addAction(Action(PredefinedAction.CROPPED, DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.EDITED, DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.CROPPED, DigitalSourceType.DIGITAL_CAPTURE)) * ``` * * @see Action * @see PredefinedAction */ @Throws(C2PAError::class) - fun addAction(action: Action) { + fun addAction(action: Action): Builder { val result = addActionNative(ptr, action.toJson()) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add action") } + return this } - /** Set the no-embed flag */ - fun setNoEmbed() = setNoEmbedNative(ptr) + /** + * Sets the no-embed flag, preventing the manifest from being embedded in the asset. + * + * @return This builder for fluent chaining + */ + fun setNoEmbed(): Builder { + setNoEmbedNative(ptr) + return this + } - /** Set the remote URL */ + /** + * Sets a remote URL where the manifest will be hosted. + * + * @param url The remote URL for the manifest + * @return This builder for fluent chaining + * @throws C2PAError.Api if the remote URL cannot be set + */ @Throws(C2PAError::class) - fun setRemoteURL(url: String) { + fun setRemoteURL(url: String): Builder { val result = setRemoteUrlNative(ptr, url) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to set remote URL") } + return this } - /** Add a resource to the builder */ + /** + * Adds a resource to the builder. + * + * @param uri The URI identifying the resource + * @param stream The stream containing the resource data + * @return This builder for fluent chaining + * @throws C2PAError.Api if the resource cannot be added + */ @Throws(C2PAError::class) - fun addResource(uri: String, stream: Stream) { + fun addResource(uri: String, stream: Stream): Builder { val result = addResourceNative(ptr, uri, stream.rawPtr) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add resource") } + return this } - /** Add an ingredient from a stream */ + /** + * Adds an ingredient from a stream. + * + * @param ingredientJSON JSON describing the ingredient + * @param format The MIME type of the ingredient (e.g., "image/jpeg") + * @param source The stream containing the ingredient data + * @return This builder for fluent chaining + * @throws C2PAError.Api if the ingredient cannot be added + */ @Throws(C2PAError::class) - fun addIngredient(ingredientJSON: String, format: String, source: Stream) { + fun addIngredient(ingredientJSON: String, format: String, source: Stream): Builder { val result = addIngredientFromStreamNative(ptr, ingredientJSON, format, source.rawPtr) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add ingredient") } + return this } - /** Write the builder to an archive */ + /** + * Writes the builder state to an archive stream. + * + * Archives are portable representations of a manifest and its associated resources + * that can later be loaded with [fromArchive] or [withArchive]. + * + * @param dest The output stream to write the archive to + * @throws C2PAError.Api if the archive cannot be written + */ @Throws(C2PAError::class) fun toArchive(dest: Stream) { val result = toArchiveNative(ptr, dest.rawPtr) @@ -273,7 +466,20 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } } - /** Sign and write the manifest */ + /** + * Signs the manifest and writes the signed asset to the destination stream. + * + * This is the primary method for producing a signed C2PA asset. The source stream + * provides the original asset data, and the signed output (with embedded manifest) + * is written to the destination stream. + * + * @param format The MIME type of the asset (e.g., "image/jpeg", "image/png") + * @param source The input stream containing the original asset + * @param dest The output stream for the signed asset + * @param signer The [Signer] to use for signing + * @return A [SignResult] containing the manifest size and optional manifest bytes + * @throws C2PAError.Api if signing fails + */ @Throws(C2PAError::class) fun sign(format: String, source: Stream, dest: Stream, signer: Signer): SignResult { val result = signNative(ptr, format, source.rawPtr, dest.rawPtr, signer.ptr) @@ -283,7 +489,20 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** Create a hashed placeholder for later signing */ + /** + * Creates a data-hashed placeholder for deferred signing workflows. + * + * This generates a placeholder manifest that can be embedded in an asset before + * the final signature is applied. Use [signDataHashedEmbeddable] to produce the + * final signed manifest after computing the asset's data hash. + * + * @param reservedSize The number of bytes to reserve for the manifest + * @param format The MIME type of the asset (e.g., "image/jpeg") + * @return The placeholder manifest as a byte array + * @throws C2PAError.Api if the placeholder cannot be created + * + * @see signDataHashedEmbeddable + */ @Throws(C2PAError::class) fun dataHashedPlaceholder(reservedSize: Long, format: String): ByteArray { val result = dataHashedPlaceholderNative(ptr, reservedSize, format) @@ -293,7 +512,22 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** Sign using data hash (advanced use) */ + /** + * Produces a signed manifest using a pre-computed data hash. + * + * This completes the deferred signing workflow started with [dataHashedPlaceholder]. + * The caller provides the hash of the asset data, and this method returns the final + * signed manifest bytes that can be embedded in the asset. + * + * @param signer The [Signer] to use for signing + * @param dataHash The hex-encoded hash of the asset data + * @param format The MIME type of the asset (e.g., "image/jpeg") + * @param asset Optional stream containing the asset (used for validation) + * @return The signed manifest as a byte array + * @throws C2PAError.Api if signing fails + * + * @see dataHashedPlaceholder + */ @Throws(C2PAError::class) fun signDataHashedEmbeddable(signer: Signer, dataHash: String, format: String, asset: Stream? = null): ByteArray { val result = @@ -318,6 +552,8 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } private external fun free(handle: Long) + private external fun withDefinitionNative(handle: Long, manifestJson: String): Long + private external fun withArchiveNative(handle: Long, streamHandle: Long): Long private external fun setIntentNative(handle: Long, intent: Int, digitalSourceType: Int): Int private external fun addActionNative(handle: Long, actionJson: String): Int private external fun setNoEmbedNative(handle: Long) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt new file mode 100644 index 0000000..e9ce847 --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt @@ -0,0 +1,105 @@ +/* +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. +*/ + +package org.contentauth.c2pa + +import java.io.Closeable + +/** + * C2PA Context for creating readers and builders with shared configuration. + * + * C2PAContext wraps the native C2PAContext struct and provides an immutable, shareable + * configuration context. Once created, a context can be used to create multiple + * [Reader] and [Builder] instances that share the same settings. + * + * ## Usage + * + * ### Default context + * ```kotlin + * val context = C2PAContext.create() + * val builder = Builder.fromContext(context) + * val reader = Reader.fromContext(context) + * context.close() + * ``` + * + * ### Custom settings + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * + * val context = C2PAContext.fromSettings(settings) + * settings.close() + * + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + * + * ## Resource Management + * + * C2PAContext implements [Closeable] and must be closed when done to free native resources. + * The context can be closed after creating readers/builders from it. + * + * @property ptr Internal pointer to the native C2PAContext instance + * @see C2PASettings + * @see Builder + * @see Reader + * @since 1.0.0 + */ +class C2PAContext internal constructor(internal var ptr: Long) : Closeable { + + companion object { + init { + loadC2PALibraries() + } + + /** + * Creates a context with default settings. + * + * @return A new [C2PAContext] with default configuration + * @throws C2PAError.Api if the context cannot be created + */ + @JvmStatic + @Throws(C2PAError::class) + fun create(): C2PAContext = executeC2PAOperation("Failed to create C2PAContext") { + val handle = nativeNew() + if (handle == 0L) null else C2PAContext(handle) + } + + /** + * Creates a context with custom settings. + * + * The settings are cloned internally, so the caller retains ownership of [settings]. + * + * @param settings The settings to configure this context with + * @return A new [C2PAContext] configured with the provided settings + * @throws C2PAError.Api if the context cannot be created with the given settings + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromSettings(settings: C2PASettings): C2PAContext = executeC2PAOperation("Failed to create C2PAContext with settings") { + val handle = nativeNewWithSettings(settings.ptr) + if (handle == 0L) null else C2PAContext(handle) + } + + @JvmStatic private external fun nativeNew(): Long + @JvmStatic private external fun nativeNewWithSettings(settingsPtr: Long): Long + } + + override fun close() { + if (ptr != 0L) { + free(ptr) + ptr = 0 + } + } + + private external fun free(handle: Long) +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt new file mode 100644 index 0000000..288f9aa --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt @@ -0,0 +1,42 @@ +/* +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. +*/ + +package org.contentauth.c2pa + +import kotlinx.serialization.json.Json + +/** + * Centralized JSON configuration for C2PA manifests and settings. + */ +object C2PAJson { + + /** + * Default JSON configuration for C2PA manifest serialization. + * + * Settings: + * - Does not encode default values (smaller output) + * - Ignores unknown keys (forward compatibility with newer C2PA versions) + */ + val default: Json = Json { + encodeDefaults = false + ignoreUnknownKeys = true + } + + /** + * Pretty-printed JSON configuration for debugging and display. + */ + val pretty: Json = Json { + encodeDefaults = false + ignoreUnknownKeys = true + prettyPrint = true + } +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt new file mode 100644 index 0000000..9956bef --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt @@ -0,0 +1,109 @@ +/* +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. +*/ + +package org.contentauth.c2pa + +import java.io.Closeable + +/** + * C2PA Settings for configuring context-based operations. + * + * C2PASettings wraps the native C2PASettings struct and provides a fluent API for + * configuring settings that can be passed to [C2PAContext]. + * + * ## Usage + * + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString("""{"version": 1, "builder": {"created_assertion_labels": ["c2pa.actions"]}}""", "json") + * .setValue("verify.verify_after_sign", "true") + * + * val context = C2PAContext.fromSettings(settings) + * settings.close() // settings can be closed after creating the context + * ``` + * + * ## Resource Management + * + * C2PASettings implements [Closeable] and must be closed when done to free native resources. + * + * @property ptr Internal pointer to the native C2PASettings instance + * @see C2PAContext + * @since 1.0.0 + */ +class C2PASettings internal constructor(internal var ptr: Long) : Closeable { + + companion object { + init { + loadC2PALibraries() + } + + /** + * Creates a new settings instance with default values. + * + * @return A new [C2PASettings] instance + * @throws C2PAError.Api if the settings cannot be created + */ + @JvmStatic + @Throws(C2PAError::class) + fun create(): C2PASettings = executeC2PAOperation("Failed to create C2PASettings") { + val handle = nativeNew() + if (handle == 0L) null else C2PASettings(handle) + } + + @JvmStatic private external fun nativeNew(): Long + } + + /** + * Updates settings from a JSON or TOML string. + * + * @param settingsStr The settings string in JSON or TOML format + * @param format The format of the string ("json" or "toml") + * @return This settings instance for fluent chaining + * @throws C2PAError.Api if the settings string is invalid + */ + @Throws(C2PAError::class) + fun updateFromString(settingsStr: String, format: String): C2PASettings { + val result = updateFromStringNative(ptr, settingsStr, format) + if (result < 0) { + throw C2PAError.Api(C2PA.getError() ?: "Failed to update settings from string") + } + return this + } + + /** + * Sets a specific configuration value using dot notation. + * + * @param path Dot-separated path (e.g., "verify.verify_after_sign") + * @param value JSON value as a string (e.g., "true", "\"ps256\"", "42") + * @return This settings instance for fluent chaining + * @throws C2PAError.Api if the path or value is invalid + */ + @Throws(C2PAError::class) + fun setValue(path: String, value: String): C2PASettings { + val result = setValueNative(ptr, path, value) + if (result < 0) { + throw C2PAError.Api(C2PA.getError() ?: "Failed to set settings value") + } + return this + } + + override fun close() { + if (ptr != 0L) { + free(ptr) + ptr = 0 + } + } + + private external fun free(handle: Long) + private external fun updateFromStringNative(handle: Long, settingsStr: String, format: String): Int + private external fun setValueNative(handle: Long, path: String, value: String): Int +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt b/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt index 3aeb92b..eb7b110 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt @@ -1,4 +1,4 @@ -/* +/* 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. @@ -21,7 +21,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.bouncycastle.asn1.ASN1ObjectIdentifier import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers import org.bouncycastle.asn1.x500.X500Name @@ -211,8 +210,7 @@ object CertificateManager { } } - // Private helper methods - + /** Builds an X.500 distinguished name from the certificate configuration. */ private fun buildX500Name(config: CertificateConfig): X500Name { val parts = mutableListOf() parts.add("CN=${config.commonName}") @@ -225,8 +223,8 @@ object CertificateManager { return X500Name(parts.joinToString(", ")) } + /** Creates a [ContentSigner] using the Android KeyStore for the given private key. */ private fun createContentSigner(privateKey: PrivateKey): ContentSigner { - // For EC keys, use SHA256withECDSA val signatureAlgorithm = when (privateKey.algorithm) { "EC" -> "SHA256withECDSA" @@ -241,6 +239,7 @@ object CertificateManager { return AndroidKeyStoreContentSigner(privateKey, signatureAlgorithm) } + /** Converts a PKCS#10 certification request to PEM-encoded string. */ private fun csrToPEM(csr: PKCS10CertificationRequest): String { val writer = StringWriter() val pemWriter = PemWriter(writer) @@ -250,6 +249,7 @@ object CertificateManager { return writer.toString() } + /** Creates a StrongBox-backed EC key pair in the Android KeyStore. */ private fun createStrongBoxKey( config: StrongBoxSigner.Config, tempCertConfig: TempCertificateConfig = @@ -424,11 +424,6 @@ object CertificateManager { val serial_number: String, ) - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - private suspend fun submitCSR( csr: String, metadata: CSRMetadata, @@ -448,7 +443,7 @@ object CertificateManager { apiKey?.let { connection.setRequestProperty("X-API-Key", it) } val request = CSRRequest(csr, metadata) - val requestJson = json.encodeToString(request) + val requestJson = C2PAJson.default.encodeToString(request) connection.outputStream.use { output -> output.write(requestJson.toByteArray()) @@ -458,7 +453,7 @@ object CertificateManager { val response = connection.inputStream.bufferedReader().use { it.readText() } connection.disconnect() - val csrResponse = json.decodeFromString(response) + val csrResponse = C2PAJson.default.decodeFromString(response) Result.success(csrResponse) } else { val error = diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt index cfa4bc5..9d0da47 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt @@ -102,6 +102,37 @@ class Reader internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Reader(handle) } + /** + * Creates a reader from a shared [C2PAContext]. + * + * The context can be reused to create multiple readers and builders. + * The reader will inherit the context's settings. Use [withStream] or + * [withFragment] to configure the reader with media data. + * + * @param context The context to create the reader from + * @return A Reader instance configured with the context's settings + * @throws C2PAError.Api if the reader cannot be created + * + * @sample + * ```kotlin + * val context = C2PAContext.create() + * val reader = Reader.fromContext(context) + * .withStream("image/jpeg", stream) + * val json = reader.json() + * ``` + * + * @see C2PAContext + * @see withStream + * @see withFragment + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromContext(context: C2PAContext): Reader = + executeC2PAOperation("Failed to create reader from context") { + val handle = nativeFromContext(context.ptr) + if (handle == 0L) null else Reader(handle) + } + /** * Creates a reader from manifest data and an associated media stream. * @@ -130,6 +161,8 @@ class Reader internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Reader(handle) } + @JvmStatic private external fun nativeFromContext(contextPtr: Long): Long + @JvmStatic private external fun fromStreamNative(format: String, streamHandle: Long): Long @JvmStatic @@ -140,6 +173,62 @@ class Reader internal constructor(private var ptr: Long) : Closeable { ): Long } + /** + * Configures the reader with a media stream. + * + * @param format The MIME type of the media (e.g., "image/jpeg", "video/mp4") + * @param stream The input stream containing the media file + * @return This reader for fluent chaining + * @throws C2PAError.Api if the stream cannot be read or the format is unsupported + * + * @sample + * ```kotlin + * val reader = Reader.fromContext(context) + * .withStream("image/jpeg", stream) + * val json = reader.json() + * ``` + */ + @Throws(C2PAError::class) + fun withStream(format: String, stream: Stream): Reader { + val newPtr = withStreamNative(ptr, format, stream.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to configure reader with stream") + } + ptr = newPtr + return this + } + + /** + * Configures the reader with a fragment stream for fragmented media. + * + * This is used for fragmented BMFF media formats where manifests are stored + * in separate fragments. + * + * @param format The MIME type of the media (e.g., "video/mp4") + * @param stream The main asset stream + * @param fragment The fragment stream + * @return This reader for fluent chaining + * @throws C2PAError.Api if the streams cannot be read or the format is unsupported + * + * @sample + * ```kotlin + * val reader = Reader.fromContext(context) + * .withFragment("video/mp4", mainStream, fragmentStream) + * val json = reader.json() + * ``` + */ + @Throws(C2PAError::class) + fun withFragment(format: String, stream: Stream, fragment: Stream): Reader { + val newPtr = withFragmentNative(ptr, format, stream.rawPtr, fragment.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to configure reader with fragment") + } + ptr = newPtr + return this + } + /** * Converts the C2PA manifest to a JSON string representation. * @@ -302,6 +391,8 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } private external fun free(handle: Long) + private external fun withStreamNative(handle: Long, format: String, streamHandle: Long): Long + private external fun withFragmentNative(handle: Long, format: String, streamHandle: Long, fragmentHandle: Long): Long private external fun toJsonNative(handle: Long): String? private external fun toDetailedJsonNative(handle: Long): String? private external fun remoteUrlNative(handle: Long): String? diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt b/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt index e6de3ed..912fc59 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt @@ -1,4 +1,4 @@ -/* +/* 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. @@ -12,7 +12,6 @@ each license. package org.contentauth.c2pa -import java.io.ByteArrayOutputStream import java.io.Closeable import java.io.File import java.io.IOException @@ -37,6 +36,12 @@ typealias StreamFlusher = () -> Int /** Abstract base class for C2PA streams */ abstract class Stream : Closeable { + companion object { + init { + loadC2PALibraries() + } + } + private var nativeHandle: Long = 0 internal val rawPtr: Long get() = nativeHandle @@ -96,10 +101,17 @@ class DataStream(private val data: ByteArray) : Stream() { override fun write(data: ByteArray, length: Long): Long = throw UnsupportedOperationException("DataStream is read-only") - override fun flush(): Long = 0L + + override fun flush(): Long = + throw UnsupportedOperationException("DataStream is read-only") } -/** Stream implementation with callbacks */ +/** + * Stream implementation with callbacks. + * + * Consider using the type-safe factory methods [forReading], [forWriting], or [forReadWrite] + * to ensure required callbacks are provided at compile time. + */ class CallbackStream( private val reader: StreamReader? = null, private val seeker: StreamSeeker? = null, @@ -117,7 +129,7 @@ class CallbackStream( override fun seek(offset: Long, mode: Int): Long { val seekMode = - SeekMode.values().find { it.value == mode } + SeekMode.entries.find { it.value == mode } ?: throw IllegalArgumentException("Invalid seek mode: $mode") return seeker?.invoke(offset, seekMode) ?: throw UnsupportedOperationException( @@ -137,6 +149,55 @@ class CallbackStream( ?: throw UnsupportedOperationException( "Flush operation not supported: no flusher callback provided", ) + + companion object { + /** + * Creates a read-only callback stream. + * + * @param reader Callback to read data into a buffer, returning bytes read. + * @param seeker Callback to seek to a position, returning the new position. + * @return A CallbackStream configured for reading. + */ + fun forReading( + reader: StreamReader, + seeker: StreamSeeker, + ): CallbackStream = CallbackStream(reader = reader, seeker = seeker) + + /** + * Creates a write-only callback stream. + * + * @param writer Callback to write data from a buffer, returning bytes written. + * @param seeker Callback to seek to a position, returning the new position. + * @param flusher Callback to flush the stream, returning 0 on success. + * @return A CallbackStream configured for writing. + */ + fun forWriting( + writer: StreamWriter, + seeker: StreamSeeker, + flusher: StreamFlusher, + ): CallbackStream = CallbackStream(writer = writer, seeker = seeker, flusher = flusher) + + /** + * Creates a read-write callback stream. + * + * @param reader Callback to read data into a buffer, returning bytes read. + * @param writer Callback to write data from a buffer, returning bytes written. + * @param seeker Callback to seek to a position, returning the new position. + * @param flusher Callback to flush the stream, returning 0 on success. + * @return A CallbackStream configured for both reading and writing. + */ + fun forReadWrite( + reader: StreamReader, + writer: StreamWriter, + seeker: StreamSeeker, + flusher: StreamFlusher, + ): CallbackStream = CallbackStream( + reader = reader, + writer = writer, + seeker = seeker, + flusher = flusher, + ) + } } /** File-based stream implementation */ @@ -223,17 +284,13 @@ class FileStream(fileURL: File, mode: Mode = Mode.READ_WRITE, createIfNeeded: Bo * output. */ class ByteArrayStream(initialData: ByteArray? = null) : Stream() { - private val buffer = ByteArrayOutputStream() + private var data: ByteArray = initialData?.copyOf() ?: ByteArray(0) private var position = 0 - private var data: ByteArray = initialData ?: ByteArray(0) - - init { - initialData?.let { buffer.write(it) } - } + private var size = data.size override fun read(buffer: ByteArray, length: Long): Long { - if (position >= data.size) return 0 - val toRead = minOf(length.toInt(), data.size - position) + if (position >= size) return 0 + val toRead = minOf(length.toInt(), size - position) System.arraycopy(data, position, buffer, 0, toRead) position += toRead return toRead.toLong() @@ -244,42 +301,37 @@ class ByteArrayStream(initialData: ByteArray? = null) : Stream() { when (mode) { SeekMode.START.value -> offset.toInt() SeekMode.CURRENT.value -> position + offset.toInt() - SeekMode.END.value -> data.size + offset.toInt() + SeekMode.END.value -> size + offset.toInt() else -> return -1L } - position = position.coerceIn(0, data.size) + position = position.coerceIn(0, size) return position.toLong() } - override fun write(writeData: ByteArray, length: Long): Long { + override fun write(data: ByteArray, length: Long): Long { val len = length.toInt() - if (position < data.size) { - // Writing in the middle - need to handle carefully - val newData = data.toMutableList() - for (i in 0 until len) { - if (position + i < newData.size) { - newData[position + i] = writeData[i] - } else { - newData.add(writeData[i]) - } - } - data = newData.toByteArray() - buffer.reset() - buffer.write(data) - } else { - // Appending - buffer.write(writeData, 0, len) - data = buffer.toByteArray() + val requiredCapacity = position + len + + // Expand buffer if needed (grow by 2x or to required size, whichever is larger) + if (requiredCapacity > this.data.size) { + val newCapacity = maxOf(this.data.size * 2, requiredCapacity) + this.data = this.data.copyOf(newCapacity) } + + // Copy data directly into buffer + System.arraycopy(data, 0, this.data, position, len) position += len - return length - } - override fun flush(): Long { - data = buffer.toByteArray() - return 0 + // Update size if we wrote past the current end + if (position > size) { + size = position + } + + return len.toLong() } + override fun flush(): Long = 0 + /** Get the current data in the stream */ - fun getData(): ByteArray = data + fun getData(): ByteArray = data.copyOf(size) } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt index 9419104..c47d696 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt @@ -1,4 +1,4 @@ -/* +/* 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. @@ -14,7 +14,6 @@ package org.contentauth.c2pa import android.util.Base64 import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -39,12 +38,6 @@ class WebServiceSigner( private val bearerToken: String? = null, private val customHeaders: Map = emptyMap(), ) { - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - private val httpClient = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -98,13 +91,13 @@ class WebServiceSigner( val responseBody = response.body?.string() ?: throw SignerException.InvalidResponse - return json.decodeFromString(responseBody) + return C2PAJson.default.decodeFromString(responseBody) } private fun signData(data: ByteArray, signingURL: String): ByteArray { val dataToSignBase64 = Base64.encodeToString(data, Base64.NO_WRAP) val requestJson = - json.encodeToString(SignRequest.serializer(), SignRequest(claim = dataToSignBase64)) + C2PAJson.default.encodeToString(SignRequest.serializer(), SignRequest(claim = dataToSignBase64)) val requestBuilder = Request.Builder() @@ -124,7 +117,7 @@ class WebServiceSigner( } val responseBody = response.body?.string() ?: throw SignerException.InvalidResponse - val signResponse = json.decodeFromString(responseBody) + val signResponse = C2PAJson.default.decodeFromString(responseBody) return Base64.decode(signResponse.signature, Base64.NO_WRAP) } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/SettingsValidator.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/SettingsValidator.kt new file mode 100644 index 0000000..8ec3951 --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/SettingsValidator.kt @@ -0,0 +1,725 @@ +/* +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. +*/ + +package org.contentauth.c2pa.manifest + +import android.util.Log +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.contentauth.c2pa.C2PAJson +import org.contentauth.c2pa.SigningAlgorithm + +/** + * Validates C2PA settings JSON/TOML for schema compliance and provides warnings for common issues. + * + * This validator checks settings against the C2PA settings schema documented at: + * https://opensource.contentauthenticity.org/docs/c2pa-rs/settings + * + * ## Usage + * + * ```kotlin + * val settingsJson = """{"version": 1, "verify": {"verify_trust": false}}""" + * val result = SettingsValidator.validate(settingsJson) + * if (result.hasErrors()) { + * result.errors.forEach { println("Error: $it") } + * } + * if (result.hasWarnings()) { + * result.warnings.forEach { println("Warning: $it") } + * } + * ``` + */ +object SettingsValidator { + + private const val TAG = "C2PA" + + /** + * Currently supported settings format version. + */ + const val SUPPORTED_VERSION = 1 + + /** + * Valid signing algorithms for C2PA, derived from [SigningAlgorithm] enum values. + */ + val VALID_ALGORITHMS: Set = SigningAlgorithm.entries.map { it.description }.toSet() + + /** + * Valid thumbnail formats. + */ + val VALID_THUMBNAIL_FORMATS: Set = setOf("jpeg", "png", "webp") + + /** + * Valid thumbnail quality settings. + */ + val VALID_THUMBNAIL_QUALITIES: Set = setOf("low", "medium", "high") + + /** + * Valid intent string values (non-object form). + */ + val VALID_INTENT_STRINGS: Set = setOf("Edit", "Update") + + /** + * Valid digital source types for actions. + */ + val VALID_SOURCE_TYPES: Set = setOf( + "empty", + "digitalCapture", + "negativeFilm", + "positiveFilm", + "print", + "minorHumanEdits", + "compositeCapture", + "algorithmicallyEnhanced", + "dataDrivenMedia", + "digitalArt", + "compositeWithTrainedAlgorithmicMedia", + "compositeSynthetic", + "trainedAlgorithmicMedia", + "algorithmicMedia", + "virtualRecording", + "composite", + "softwareRendered", + "generatedByAI", + ) + + /** + * Known top-level settings sections. + */ + val KNOWN_TOP_LEVEL_KEYS: Set = setOf( + "version", + "trust", + "cawg_trust", + "core", + "verify", + "builder", + "signer", + "cawg_x509_signer", + ) + + /** + * Validates a settings JSON string. + * + * @param settingsJson The settings JSON string to validate. + * @param logWarnings If true (default), warnings are logged to the Android console. + * @return A ValidationResult with any errors or warnings found. + */ + fun validate(settingsJson: String, logWarnings: Boolean = true): ValidationResult { + val errors = mutableListOf() + val warnings = mutableListOf() + + try { + val jsonObject = C2PAJson.default.parseToJsonElement(settingsJson).jsonObject + validateSettingsObject(jsonObject, errors, warnings) + } catch (e: Exception) { + errors.add("Failed to parse settings JSON: ${e.message}") + } + + if (logWarnings) { + logValidationResults(errors, warnings) + } + + return ValidationResult(errors, warnings) + } + + /** + * Validates a parsed settings JSON object. + */ + private fun validateSettingsObject( + settings: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + // Check for unknown top-level keys + settings.keys.forEach { key -> + if (key !in KNOWN_TOP_LEVEL_KEYS) { + warnings.add("Unknown top-level key: '$key'") + } + } + + // Validate version + validateVersion(settings, errors) + + // Validate each section + settings["trust"]?.jsonObject?.let { validateTrustSection(it, "trust", errors, warnings) } + settings["cawg_trust"]?.jsonObject?.let { validateCawgTrustSection(it, errors, warnings) } + settings["core"]?.jsonObject?.let { validateCoreSection(it, errors, warnings) } + settings["verify"]?.jsonObject?.let { validateVerifySection(it, errors, warnings) } + settings["builder"]?.jsonObject?.let { validateBuilderSection(it, errors, warnings) } + settings["signer"]?.jsonObject?.let { validateSignerSection(it, "signer", errors, warnings) } + settings["cawg_x509_signer"]?.jsonObject?.let { + validateSignerSection(it, "cawg_x509_signer", errors, warnings) + } + } + + /** + * Validates the version field. + */ + private fun validateVersion(settings: JsonObject, errors: MutableList) { + val version = settings["version"]?.jsonPrimitive?.intOrNull + if (version == null) { + errors.add("'version' is required and must be an integer") + } else if (version != SUPPORTED_VERSION) { + errors.add("'version' must be $SUPPORTED_VERSION, got $version") + } + } + + /** + * Validates trust section (shared structure for trust and cawg_trust). + */ + private fun validateTrustSection( + trust: JsonObject, + sectionName: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("user_anchors", "trust_anchors", "trust_config", "allowed_list", "verify_trust_list") + + trust.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $sectionName: '$key'") + } + } + + // Validate PEM format for certificate fields + listOf("user_anchors", "trust_anchors", "allowed_list").forEach { field -> + trust[field]?.jsonPrimitive?.content?.let { pemString -> + if (!isValidPEM(pemString, "CERTIFICATE")) { + errors.add("$sectionName.$field must be valid PEM-formatted certificate(s)") + } + } + } + } + + /** + * Validates cawg_trust section. + */ + private fun validateCawgTrustSection( + cawgTrust: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + validateTrustSection(cawgTrust, "cawg_trust", errors, warnings) + + // verify_trust_list specific to cawg_trust + cawgTrust["verify_trust_list"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("cawg_trust.verify_trust_list must be a boolean") + } + } + } + + /** + * Validates core section. + */ + private fun validateCoreSection( + core: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "merkle_tree_chunk_size_in_kb", + "merkle_tree_max_proofs", + "backing_store_memory_threshold_in_mb", + "decode_identity_assertions", + "allowed_network_hosts", + ) + + core.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in core: '$key'") + } + } + + // Validate numeric fields + listOf( + "merkle_tree_chunk_size_in_kb", + "merkle_tree_max_proofs", + "backing_store_memory_threshold_in_mb", + ).forEach { field -> + core[field]?.let { element -> + if (element.jsonPrimitive.intOrNull == null) { + errors.add("core.$field must be a number") + } + } + } + + // Validate boolean field + core["decode_identity_assertions"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("core.decode_identity_assertions must be a boolean") + } + } + + // Validate allowed_network_hosts is an array + core["allowed_network_hosts"]?.let { element -> + try { + element.jsonArray + } catch (e: Exception) { + errors.add("core.allowed_network_hosts must be an array") + } + } + } + + /** + * Validates verify section. + */ + private fun validateVerifySection( + verify: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "verify_after_reading", + "verify_after_sign", + "verify_trust", + "verify_timestamp_trust", + "ocsp_fetch", + "remote_manifest_fetch", + "skip_ingredient_conflict_resolution", + "strict_v1_validation", + ) + + verify.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in verify: '$key'") + } + } + + // All verify fields are booleans + validKeys.forEach { field -> + verify[field]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("verify.$field must be a boolean") + } + } + } + + // Warn about disabling verification + listOf("verify_trust", "verify_timestamp_trust", "verify_after_sign").forEach { field -> + verify[field]?.jsonPrimitive?.booleanOrNull?.let { value -> + if (!value) { + warnings.add( + "verify.$field is set to false. This may result in verification behavior " + + "that is not fully compliant with the C2PA specification.", + ) + } + } + } + } + + /** + * Validates builder section. + */ + private fun validateBuilderSection( + builder: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "claim_generator_info", + "certificate_status_fetch", + "certificate_status_should_override", + "intent", + "created_assertion_labels", + "generate_c2pa_archive", + "actions", + "thumbnail", + ) + + builder.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in builder: '$key'") + } + } + + // Validate intent + builder["intent"]?.let { validateIntent(it, errors) } + + // Validate thumbnail + builder["thumbnail"]?.jsonObject?.let { validateThumbnailSection(it, errors, warnings) } + + // Validate actions + builder["actions"]?.jsonObject?.let { validateActionsSection(it, errors, warnings) } + + // Validate claim_generator_info + builder["claim_generator_info"]?.jsonObject?.let { info -> + if (info["name"] == null) { + errors.add("builder.claim_generator_info.name is required when claim_generator_info is specified") + } + } + + // Validate created_assertion_labels is an array + builder["created_assertion_labels"]?.let { element -> + try { + element.jsonArray + } catch (e: Exception) { + errors.add("builder.created_assertion_labels must be an array") + } + } + + // Validate boolean fields + builder["generate_c2pa_archive"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("builder.generate_c2pa_archive must be a boolean") + } + } + } + + /** + * Validates intent value. + */ + private fun validateIntent(intent: JsonElement, errors: MutableList) { + when { + intent is JsonPrimitive && intent.isString -> { + val intentString = intent.content + if (intentString !in VALID_INTENT_STRINGS) { + errors.add( + "builder.intent string must be one of: ${VALID_INTENT_STRINGS.joinToString()}, " + + "got '$intentString'", + ) + } + } + intent is JsonObject -> { + // Should be {"Create": "sourceType"} + val createValue = intent["Create"]?.jsonPrimitive?.content + if (createValue == null) { + errors.add("builder.intent object must have 'Create' key with source type value") + } else if (createValue !in VALID_SOURCE_TYPES) { + errors.add( + "builder.intent Create source type must be one of: ${VALID_SOURCE_TYPES.joinToString()}, " + + "got '$createValue'", + ) + } + } + else -> { + errors.add("builder.intent must be a string ('Edit', 'Update') or object ({\"Create\": \"sourceType\"})") + } + } + } + + /** + * Validates thumbnail section. + */ + private fun validateThumbnailSection( + thumbnail: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("enabled", "ignore_errors", "long_edge", "format", "prefer_smallest_format", "quality") + + thumbnail.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in builder.thumbnail: '$key'") + } + } + + // Validate format + thumbnail["format"]?.jsonPrimitive?.content?.let { format -> + if (format !in VALID_THUMBNAIL_FORMATS) { + errors.add( + "builder.thumbnail.format must be one of: ${VALID_THUMBNAIL_FORMATS.joinToString()}, " + + "got '$format'", + ) + } + } + + // Validate quality + thumbnail["quality"]?.jsonPrimitive?.content?.let { quality -> + if (quality !in VALID_THUMBNAIL_QUALITIES) { + errors.add( + "builder.thumbnail.quality must be one of: ${VALID_THUMBNAIL_QUALITIES.joinToString()}, " + + "got '$quality'", + ) + } + } + + // Validate long_edge is a number + thumbnail["long_edge"]?.let { element -> + if (element.jsonPrimitive.intOrNull == null) { + errors.add("builder.thumbnail.long_edge must be a number") + } + } + + // Validate boolean fields + listOf("enabled", "ignore_errors", "prefer_smallest_format").forEach { field -> + thumbnail[field]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("builder.thumbnail.$field must be a boolean") + } + } + } + } + + /** + * Validates actions section. + */ + private fun validateActionsSection( + actions: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "all_actions_included", + "templates", + "actions", + "auto_created_action", + "auto_opened_action", + "auto_placed_action", + ) + + actions.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in builder.actions: '$key'") + } + } + + // Validate auto action sections + listOf("auto_created_action", "auto_opened_action", "auto_placed_action").forEach { actionType -> + actions[actionType]?.jsonObject?.let { autoAction -> + validateAutoAction(autoAction, "builder.actions.$actionType", errors, warnings) + } + } + } + + /** + * Validates auto action configuration. + */ + private fun validateAutoAction( + autoAction: JsonObject, + path: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("enabled", "source_type") + + autoAction.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $path: '$key'") + } + } + + // Validate enabled is boolean + autoAction["enabled"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("$path.enabled must be a boolean") + } + } + + // Validate source_type + autoAction["source_type"]?.jsonPrimitive?.content?.let { sourceType -> + if (sourceType !in VALID_SOURCE_TYPES) { + errors.add( + "$path.source_type must be one of: ${VALID_SOURCE_TYPES.joinToString()}, " + + "got '$sourceType'", + ) + } + } + } + + /** + * Validates signer section (local or remote). + */ + private fun validateSignerSection( + signer: JsonObject, + sectionName: String, + errors: MutableList, + warnings: MutableList, + ) { + val hasLocal = signer["local"] != null + val hasRemote = signer["remote"] != null + + if (hasLocal && hasRemote) { + errors.add("$sectionName cannot have both 'local' and 'remote' configurations") + } + + if (!hasLocal && !hasRemote) { + errors.add("$sectionName must have either 'local' or 'remote' configuration") + } + + signer["local"]?.jsonObject?.let { local -> + validateLocalSigner(local, "$sectionName.local", errors, warnings) + } + + signer["remote"]?.jsonObject?.let { remote -> + validateRemoteSigner(remote, "$sectionName.remote", errors, warnings) + } + } + + /** + * Validates local signer configuration. + */ + private fun validateLocalSigner( + local: JsonObject, + path: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("alg", "sign_cert", "private_key", "tsa_url") + + local.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $path: '$key'") + } + } + + // Required fields + if (local["alg"] == null) { + errors.add("$path.alg is required") + } + if (local["sign_cert"] == null) { + errors.add("$path.sign_cert is required") + } + if (local["private_key"] == null) { + errors.add("$path.private_key is required") + } + + // Validate algorithm + local["alg"]?.jsonPrimitive?.content?.let { alg -> + if (alg.lowercase() !in VALID_ALGORITHMS) { + errors.add( + "$path.alg must be one of: ${VALID_ALGORITHMS.joinToString()}, " + + "got '$alg'", + ) + } + } + + // Validate PEM formats + local["sign_cert"]?.jsonPrimitive?.content?.let { cert -> + if (!isValidPEM(cert, "CERTIFICATE")) { + errors.add("$path.sign_cert must be valid PEM-formatted certificate(s)") + } + } + + local["private_key"]?.jsonPrimitive?.content?.let { key -> + if (!isValidPEM(key, "PRIVATE KEY") && !isValidPEM(key, "RSA PRIVATE KEY") && + !isValidPEM(key, "EC PRIVATE KEY") + ) { + errors.add("$path.private_key must be valid PEM-formatted private key") + } + } + + // Validate TSA URL + local["tsa_url"]?.jsonPrimitive?.content?.let { url -> + if (!isValidUrl(url)) { + errors.add("$path.tsa_url must be a valid URL") + } + } + } + + /** + * Validates remote signer configuration. + */ + private fun validateRemoteSigner( + remote: JsonObject, + path: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("url", "alg", "sign_cert", "tsa_url") + + remote.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $path: '$key'") + } + } + + // Required fields + if (remote["url"] == null) { + errors.add("$path.url is required") + } + if (remote["alg"] == null) { + errors.add("$path.alg is required") + } + if (remote["sign_cert"] == null) { + errors.add("$path.sign_cert is required") + } + + // Validate URL + remote["url"]?.jsonPrimitive?.content?.let { url -> + if (!isValidUrl(url)) { + errors.add("$path.url must be a valid URL") + } + } + + // Validate algorithm + remote["alg"]?.jsonPrimitive?.content?.let { alg -> + if (alg.lowercase() !in VALID_ALGORITHMS) { + errors.add( + "$path.alg must be one of: ${VALID_ALGORITHMS.joinToString()}, " + + "got '$alg'", + ) + } + } + + // Validate PEM format for certificate + remote["sign_cert"]?.jsonPrimitive?.content?.let { cert -> + if (!isValidPEM(cert, "CERTIFICATE")) { + errors.add("$path.sign_cert must be valid PEM-formatted certificate(s)") + } + } + + // Validate TSA URL + remote["tsa_url"]?.jsonPrimitive?.content?.let { url -> + if (!isValidUrl(url)) { + errors.add("$path.tsa_url must be a valid URL") + } + } + } + + /** + * Checks if a string is valid PEM format. + */ + private fun isValidPEM(pemString: String, expectedType: String): Boolean { + val beginMarker = "-----BEGIN $expectedType-----" + val endMarker = "-----END $expectedType-----" + return pemString.contains(beginMarker) && pemString.contains(endMarker) + } + + /** + * Checks if a string is a valid URL. + */ + private fun isValidUrl(url: String): Boolean { + return try { + val parsed = java.net.URL(url) + parsed.protocol in listOf("http", "https") + } catch (e: Exception) { + false + } + } + + /** + * Logs validation results to the Android console. + */ + private fun logValidationResults(errors: List, warnings: List) { + errors.forEach { error -> + Log.e(TAG, "Settings validation error: $error") + } + warnings.forEach { warning -> + Log.w(TAG, "Settings validation warning: $warning") + } + } + + /** + * Validates settings and logs warnings. + * + * Convenience method that validates and logs in one call. + * + * @param settingsJson The settings JSON string to validate. + * @return A ValidationResult with any errors or warnings found. + */ + fun validateAndLog(settingsJson: String): ValidationResult = validate(settingsJson, logWarnings = true) +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt new file mode 100644 index 0000000..2e338f6 --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt @@ -0,0 +1,37 @@ +/* +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. +*/ + +package org.contentauth.c2pa.manifest + +/** + * Result of validation containing errors and warnings. + * + * Used by [ManifestValidator] and [SettingsValidator] to report validation outcomes. + * + * @property errors Critical issues that violate spec or schema requirements. + * @property warnings Non-critical issues that may indicate misuse or misconfiguration. + * @see ManifestValidator + * @see SettingsValidator + */ +data class ValidationResult( + val errors: List = emptyList(), + val warnings: List = emptyList(), +) { + /** Returns true if there are any errors. */ + fun hasErrors(): Boolean = errors.isNotEmpty() + + /** Returns true if there are any warnings. */ + fun hasWarnings(): Boolean = warnings.isNotEmpty() + + /** Returns true if validation passed without errors. */ + fun isValid(): Boolean = !hasErrors() +} diff --git a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt index c200f92..8d0c374 100644 --- a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt +++ b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.withContext import org.contentauth.c2pa.test.shared.BuilderTests import org.contentauth.c2pa.test.shared.CoreTests import org.contentauth.c2pa.test.shared.ManifestTests +import org.contentauth.c2pa.test.shared.SettingsValidatorTests import org.contentauth.c2pa.test.shared.SignerTests import org.contentauth.c2pa.test.shared.StreamTests import org.contentauth.c2pa.test.shared.TestBase @@ -129,6 +130,16 @@ private class AppWebServiceTests(private val context: Context) : WebServiceTests copyResourceToCache(context, resourceName, fileName) } +private class AppSettingsValidatorTests(private val context: Context) : SettingsValidatorTests() { + override fun getContext(): Context = context + override fun loadResourceAsBytes(resourceName: String): ByteArray = loadResourceWithExtensions(resourceName) + ?: throw IllegalArgumentException("Resource not found: $resourceName") + override fun loadResourceAsString(resourceName: String): String = loadResourceStringWithExtensions(resourceName) + ?: throw IllegalArgumentException("Resource not found: $resourceName") + override fun copyResourceToFile(resourceName: String, fileName: String): File = + copyResourceToCache(context, resourceName, fileName) +} + private class AppManifestTests(private val context: Context) : ManifestTests() { override fun getContext(): Context = context override fun loadResourceAsBytes(resourceName: String): ByteArray = loadResourceWithExtensions(resourceName) @@ -204,7 +215,9 @@ private suspend fun runAllTests(context: Context): List = withContex results.add(coreTests.testConcurrentOperations()) results.add(coreTests.testReaderResourceErrorHandling()) - // Additional Stream Tests (large buffer handling) + // Additional Stream Tests + results.add(streamTests.testCallbackStreamFactories()) + results.add(streamTests.testByteArrayStreamBufferGrowth()) results.add(streamTests.testLargeBufferHandling()) // Manifest Tests @@ -229,6 +242,27 @@ private suspend fun runAllTests(context: Context): List = withContex results.add(manifestTests.testAllValidationStatusCodes()) results.add(manifestTests.testAllDigitalSourceTypes()) + // Settings Validator Tests + val settingsValidatorTests = AppSettingsValidatorTests(context) + results.add(settingsValidatorTests.testValidSettings()) + results.add(settingsValidatorTests.testInvalidJson()) + results.add(settingsValidatorTests.testMissingVersion()) + results.add(settingsValidatorTests.testWrongVersion()) + results.add(settingsValidatorTests.testUnknownTopLevelKeys()) + results.add(settingsValidatorTests.testTrustSection()) + results.add(settingsValidatorTests.testCawgTrustSection()) + results.add(settingsValidatorTests.testCoreSection()) + results.add(settingsValidatorTests.testVerifySection()) + results.add(settingsValidatorTests.testBuilderSection()) + results.add(settingsValidatorTests.testThumbnailSection()) + results.add(settingsValidatorTests.testActionsSection()) + results.add(settingsValidatorTests.testLocalSigner()) + results.add(settingsValidatorTests.testRemoteSigner()) + results.add(settingsValidatorTests.testSignerMutualExclusion()) + results.add(settingsValidatorTests.testValidationResultHelpers()) + results.add(settingsValidatorTests.testValidateAndLog()) + results.add(settingsValidatorTests.testIntentAsNumber()) + results } diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt index 046d21f..1c69e01 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt @@ -20,6 +20,8 @@ import org.contentauth.c2pa.BuilderIntent import org.contentauth.c2pa.ByteArrayStream import org.contentauth.c2pa.C2PA import org.contentauth.c2pa.C2PAError +import org.contentauth.c2pa.C2PAContext +import org.contentauth.c2pa.C2PASettings import org.contentauth.c2pa.DigitalSourceType import org.contentauth.c2pa.FileStream import org.contentauth.c2pa.PredefinedAction @@ -39,52 +41,44 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") val sourceStream = ByteArrayStream(sourceImageData) val fileTest = File.createTempFile("c2pa-stream-api-test", ".jpg") val destStream = FileStream(fileTest) - try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - - val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) - val signer = Signer.fromInfo(signerInfo) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + + val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) + Signer.fromInfo(signerInfo).use { signer -> + val result = + builder.sign( + "image/jpeg", + sourceStream, + destStream, + signer, + ) - try { - val result = - builder.sign( - "image/jpeg", - sourceStream, - destStream, - signer, + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val success = json.has("manifests") + + TestResult( + "Builder API", + success, + if (success) { + "Successfully signed image" + } else { + "Signing failed" + }, + "Original: ${sourceImageData.size}, Signed: ${fileTest.length()}, Result size: ${result.size}\n\n$json", ) - - val manifest = C2PA.readFile(fileTest.absolutePath) - val json = JSONObject(manifest) - val success = json.has("manifests") - - TestResult( - "Builder API", - success, - if (success) { - "Successfully signed image" - } else { - "Signing failed" - }, - "Original: ${sourceImageData.size}, Signed: ${fileTest.length()}, Result size: ${result.size}\n\n$json", - ) - } finally { - signer.close() + } } - } finally { - sourceStream.close() - destStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult("Builder API", false, "Failed to create builder", e.toString()) @@ -97,11 +91,9 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> builder.setNoEmbed() - val archiveStream = ByteArrayStream() - try { + ByteArrayStream().use { archiveStream -> builder.toArchive(archiveStream) val data = archiveStream.getData() val success = data.isNotEmpty() @@ -115,11 +107,7 @@ abstract class BuilderTests : TestBase() { }, "Archive size: ${data.size}", ) - } finally { - archiveStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -135,34 +123,42 @@ abstract class BuilderTests : TestBase() { suspend fun testBuilderRemoteUrl(): TestResult = withContext(Dispatchers.IO) { runTest("Builder Remote URL") { val manifestJson = TEST_MANIFEST_JSON + val remoteUrl = "https://example.com/manifest.c2pa" try { - val builder = Builder.fromJson(manifestJson) - try { - builder.setRemoteURL("https://example.com/manifest.c2pa") - builder.setNoEmbed() - val archive = ByteArrayStream() + Builder.fromJson(manifestJson).use { builder -> + builder.setRemoteURL(remoteUrl) + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-remote-url-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveData = archive.getData() - val archiveStr = String(archiveData) - val success = - archiveStr.contains("https://example.com/manifest.c2pa") - TestResult( - "Builder Remote URL", - success, - if (success) { - "Remote URL set successfully" - } else { - "Remote URL not found in archive" - }, - "Archive contains URL: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + val signResult = builder.sign("image/jpeg", sourceStream, destStream, signer) + val hasManifestBytes = signResult.manifestBytes != null && signResult.manifestBytes!!.isNotEmpty() + val success = signResult.size > 0 && hasManifestBytes + TestResult( + "Builder Remote URL", + success, + if (success) { + "Remote URL set successfully" + } else { + "Remote signing failed" + }, + "Sign result size: ${signResult.size}, Has manifest bytes: $hasManifestBytes", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -180,36 +176,42 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val thumbnailData = createSimpleJPEGThumbnail() - val thumbnailStream = ByteArrayStream(thumbnailData) - try { + ByteArrayStream(thumbnailData).use { thumbnailStream -> builder.addResource("thumbnail", thumbnailStream) - builder.setNoEmbed() - val archive = ByteArrayStream() + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-resource-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveStr = String(archive.getData()) - val success = archiveStr.contains("thumbnail") - TestResult( - "Builder Add Resource", - success, - if (success) { - "Resource added successfully" - } else { - "Resource not found in archive" - }, - "Thumbnail size: ${thumbnailData.size} bytes, Found in archive: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("thumbnail") + TestResult( + "Builder Add Resource", + success, + if (success) { + "Resource added successfully" + } else { + "Resource not found in signed manifest" + }, + "Thumbnail size: ${thumbnailData.size} bytes, Found in manifest: $success", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - thumbnailStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -227,43 +229,48 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val ingredientJson = """{"title": "Test Ingredient", "format": "image/jpeg"}""" val ingredientImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val ingredientStream = ByteArrayStream(ingredientImageData) - try { + ByteArrayStream(ingredientImageData).use { ingredientStream -> builder.addIngredient( ingredientJson, "image/jpeg", ingredientStream, ) - builder.setNoEmbed() - val archive = ByteArrayStream() + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-ingredient-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveStr = String(archive.getData()) - val success = - archiveStr.contains("\"title\":\"Test Ingredient\"") - TestResult( - "Builder Add Ingredient", - success, - if (success) { - "Ingredient added successfully" - } else { - "Ingredient not found in archive" - }, - "Ingredient found: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("Test Ingredient") + TestResult( + "Builder Add Ingredient", + success, + if (success) { + "Ingredient added successfully" + } else { + "Ingredient not found in signed manifest" + }, + "Ingredient found: $success", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - ingredientStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -281,32 +288,28 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val originalBuilder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { originalBuilder -> val thumbnailData = createSimpleJPEGThumbnail() - val thumbnailStream = ByteArrayStream(thumbnailData) - originalBuilder.addResource("test_thumbnail", thumbnailStream) - thumbnailStream.close() + ByteArrayStream(thumbnailData).use { thumbnailStream -> + originalBuilder.addResource("test_thumbnail", thumbnailStream) + } originalBuilder.setNoEmbed() - val archiveStream = ByteArrayStream() - try { + ByteArrayStream().use { archiveStream -> originalBuilder.toArchive(archiveStream) val archiveData = archiveStream.getData() - val newArchiveStream = ByteArrayStream(archiveData) - var builderCreated = false - try { - val newBuilder = Builder.fromArchive(newArchiveStream) - builderCreated = true - newBuilder.close() - } catch (e: Exception) { - builderCreated = false + ByteArrayStream(archiveData).use { newArchiveStream -> + try { + Builder.fromArchive(newArchiveStream).use { + builderCreated = true + } + } catch (e: Exception) { + builderCreated = false + } } - newArchiveStream.close() - val hasData = archiveData.isNotEmpty() val success = hasData && builderCreated @@ -321,11 +324,7 @@ abstract class BuilderTests : TestBase() { }, "Archive size: ${archiveData.size} bytes, Builder created: $builderCreated", ) - } finally { - archiveStream.close() } - } finally { - originalBuilder.close() } } catch (e: Exception) { TestResult( @@ -343,55 +342,44 @@ abstract class BuilderTests : TestBase() { try { val manifestJson = TEST_MANIFEST_JSON - val builder = Builder.fromJson(manifestJson) + val fileTest = File.createTempFile("c2pa-manifest-direct-sign", ".jpg") try { - val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val sourceStream = ByteArrayStream(sourceImageData) - val fileTest = File.createTempFile("c2pa-manifest-direct-sign", ".jpg") - val destStream = FileStream(fileTest) - - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = - Signer.fromInfo( - SignerInfo(SigningAlgorithm.ES256, certPem, keyPem), - ) - - val signResult = - builder.sign("image/jpeg", sourceStream, destStream, signer) - - sourceStream.close() - destStream.close() - signer.close() + val signResult = Builder.fromJson(manifestJson).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(fileTest).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo( + SignerInfo(SigningAlgorithm.ES256, certPem, keyPem), + ).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + } + } + } + } val freshImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val freshStream = ByteArrayStream(freshImageData) - - val success = + val success = ByteArrayStream(freshImageData).use { freshStream -> if (signResult.manifestBytes != null) { try { - val reader = - Reader.fromManifestAndStream( - "image/jpeg", - freshStream, - signResult.manifestBytes!!, - ) - try { + Reader.fromManifestAndStream( + "image/jpeg", + freshStream, + signResult.manifestBytes!!, + ).use { reader -> val json = reader.json() - json.contains("\"c2pa.test\"") - } finally { - reader.close() + // Check for c2pa.created action which is in TEST_MANIFEST_JSON + json.contains("\"c2pa.created\"") } } catch (_: Exception) { false } } else { val manifest = C2PA.readFile(fileTest.absolutePath) - manifest.contains("\"c2pa.test\"") + manifest.contains("\"c2pa.created\"") } - - freshStream.close() - fileTest.delete() + } TestResult( "Reader with Manifest Data", @@ -404,7 +392,7 @@ abstract class BuilderTests : TestBase() { "Manifest bytes available: ${signResult.manifestBytes != null}, Test assertion found: $success", ) } finally { - builder.close() + fileTest.delete() } } catch (e: Exception) { TestResult( @@ -420,35 +408,33 @@ abstract class BuilderTests : TestBase() { suspend fun testJsonRoundTrip(): TestResult = withContext(Dispatchers.IO) { runTest("JSON Round-trip") { val testImageData = loadResourceAsBytes("adobe_20220124_ci") - val memStream = ByteArrayStream(testImageData) try { - val reader = Reader.fromStream("image/jpeg", memStream) - try { - val originalJson = reader.json() - val json1 = JSONObject(originalJson) - - // Extract just the manifest part for rebuilding - val manifestsValue = json1.opt("manifests") - val success = - when (manifestsValue) { - is JSONArray -> manifestsValue.length() > 0 - is JSONObject -> manifestsValue.length() > 0 - else -> false - } + ByteArrayStream(testImageData).use { memStream -> + Reader.fromStream("image/jpeg", memStream).use { reader -> + val originalJson = reader.json() + val json1 = JSONObject(originalJson) - TestResult( - "JSON Round-trip", - success, - if (success) { - "JSON parsed successfully" - } else { - "Failed to parse JSON" - }, - "Manifests type: ${manifestsValue?.javaClass?.simpleName}, Has content: $success", - ) - } finally { - reader.close() + // Extract just the manifest part for rebuilding + val manifestsValue = json1.opt("manifests") + val success = + when (manifestsValue) { + is JSONArray -> manifestsValue.length() > 0 + is JSONObject -> manifestsValue.length() > 0 + else -> false + } + + TestResult( + "JSON Round-trip", + success, + if (success) { + "JSON parsed successfully" + } else { + "Failed to parse JSON" + }, + "Manifests type: ${manifestsValue?.javaClass?.simpleName}, Has content: $success", + ) + } } } catch (e: C2PAError) { TestResult( @@ -457,8 +443,6 @@ abstract class BuilderTests : TestBase() { "Failed to read manifest", e.toString(), ) - } finally { - memStream.close() } } } @@ -468,8 +452,7 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> // Test Create intent with digital source type builder.setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) @@ -479,48 +462,438 @@ abstract class BuilderTests : TestBase() { val destStream = FileStream(fileTest) try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + + // Check for c2pa.created action which should be auto-added by Create intent + val manifestStr = manifest.lowercase() + val hasCreatedAction = manifestStr.contains("c2pa.created") || + manifestStr.contains("digitalcapture") + + TestResult( + "Builder Set Intent", + true, + "Intent set and signed successfully", + "Has created action or digital source: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: C2PAError) { + TestResult( + "Builder Set Intent", + false, + "Failed to set intent", + e.toString(), + ) + } catch (e: Exception) { + TestResult( + "Builder Set Intent", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } - try { - builder.sign("image/jpeg", sourceStream, destStream, signer) + suspend fun testBuilderFromContextWithSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder from Context with Settings") { + val manifestJson = TEST_MANIFEST_JSON - val manifest = C2PA.readFile(fileTest.absolutePath) - val json = JSONObject(manifest) + try { + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": ["c2pa.actions"] + } + } + """.trimIndent() + + val builder = C2PASettings.create().use { settings -> + settings.updateFromString(settingsJson, "json") + C2PAContext.fromSettings(settings).use { context -> + Builder.fromContext(context).withDefinition(manifestJson) + } + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-context-settings-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val hasManifests = json.has("manifests") + val hasCreatedAction = manifest.contains("c2pa.created") + + val success = hasManifests && hasCreatedAction + + TestResult( + "Builder from Context with Settings", + success, + if (success) { + "Context-based builder with settings works" + } else { + "Failed to sign with context-based builder" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: C2PAError) { + TestResult( + "Builder from Context with Settings", + false, + "Failed to create builder from context", + e.toString(), + ) + } catch (e: Exception) { + TestResult( + "Builder from Context with Settings", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testBuilderFromJsonWithSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder fromJson with C2PASettings") { + val manifestJson = TEST_MANIFEST_JSON + + try { + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": ["c2pa.actions"] + } + } + """.trimIndent() + + val builder = C2PASettings.create().use { settings -> + settings.updateFromString(settingsJson, "json") + Builder.fromJson(manifestJson, settings) + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-fromjson-settings-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val hasManifests = json.has("manifests") + val hasCreatedAction = manifest.contains("c2pa.created") + val success = hasManifests && hasCreatedAction + + TestResult( + "Builder fromJson with C2PASettings", + success, + if (success) { + "fromJson(manifest, settings) works" + } else { + "Failed to sign with fromJson(manifest, settings)" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: Exception) { + TestResult( + "Builder fromJson with C2PASettings", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testBuilderWithArchive(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder withArchive") { + val manifestJson = TEST_MANIFEST_JSON + + try { + val archiveData = Builder.fromJson(manifestJson).use { originalBuilder -> + originalBuilder.setNoEmbed() + ByteArrayStream().use { archiveStream -> + originalBuilder.toArchive(archiveStream) + archiveStream.getData() + } + } + + val newBuilder = C2PAContext.create().use { context -> + ByteArrayStream(archiveData).use { newArchiveStream -> + Builder.fromContext(context).withArchive(newArchiveStream) + } + } + + val signSuccess = newBuilder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-witharchive-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + newBuilder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + manifest.contains("c2pa.created") + } + } + } + } finally { + fileTest.delete() + } + } + + val success = archiveData.isNotEmpty() && signSuccess + TestResult( + "Builder withArchive", + success, + if (success) { + "withArchive round-trip successful" + } else { + "withArchive round-trip failed" + }, + "Archive size: ${archiveData.size}, Sign success: $signSuccess", + ) + } catch (e: Exception) { + TestResult( + "Builder withArchive", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testReaderFromContext(): TestResult = withContext(Dispatchers.IO) { + runTest("Reader fromContext with withStream") { + try { + // First, sign an image so we have something to read + val fileTest = File.createTempFile("c2pa-reader-context-test", ".jpg") + try { + Builder.fromJson(TEST_MANIFEST_JSON).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(fileTest).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + } + } + } + } - // Check for c2pa.created action which should be auto-added by Create intent - val manifestStr = manifest.lowercase() - val hasCreatedAction = manifestStr.contains("c2pa.created") || - manifestStr.contains("digitalcapture") + // Now read using the context-based API + val signedData = fileTest.readBytes() + ByteArrayStream(signedData).use { signedStream -> + val reader = C2PAContext.create().use { context -> + Reader.fromContext(context).withStream("image/jpeg", signedStream) + } + + reader.use { + val json = reader.json() + val hasManifests = json.contains("manifests") + val hasCreatedAction = json.contains("c2pa.created") + val isEmbedded = reader.isEmbedded() + val remoteUrl = reader.remoteUrl() + + val success = hasManifests && hasCreatedAction && isEmbedded && remoteUrl == null TestResult( - "Builder Set Intent", - true, - "Intent set and signed successfully", - "Has created action or digital source: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + "Reader fromContext with withStream", + success, + if (success) { + "Context-based reader works" + } else { + "Context-based reader failed" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction, " + + "Is embedded: $isEmbedded, Remote URL: $remoteUrl", ) - } finally { - signer.close() + } + } + } finally { + fileTest.delete() + } + } catch (e: Exception) { + TestResult( + "Reader fromContext with withStream", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testSettingsSetValue(): TestResult = withContext(Dispatchers.IO) { + runTest("C2PASettings setValue") { + try { + val builder = C2PASettings.create().use { settings -> + settings.updateFromString("""{"version": 1}""", "json") + .setValue("verify.verify_after_sign", "false") + C2PAContext.fromSettings(settings).use { context -> + Builder.fromContext(context) + .withDefinition(TEST_MANIFEST_JSON) + } + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-setvalue-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("manifests") + + TestResult( + "C2PASettings setValue", + success, + if (success) { + "setValue works for building context" + } else { + "setValue failed" + }, + "Signed with setValue-configured settings", + ) + } + } } } finally { - sourceStream.close() - destStream.close() fileTest.delete() } - } finally { - builder.close() } - } catch (e: C2PAError) { + } catch (e: Exception) { TestResult( - "Builder Set Intent", + "C2PASettings setValue", false, - "Failed to set intent", + "Exception: ${e.message}", e.toString(), ) + } + } + } + + suspend fun testBuilderIntentEditAndUpdate(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder Intent Edit and Update") { + try { + Builder.fromJson(TEST_MANIFEST_JSON).use { builder -> + builder.setIntent(BuilderIntent.Edit) + + // Add a parent ingredient (required for Edit) + val ingredientImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(ingredientImageData).use { ingredientStream -> + builder.addIngredient( + """{"title": "Parent Image", "format": "image/jpeg"}""", + "image/jpeg", + ingredientStream, + ) + } + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-edit-intent-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val editSuccess = manifest.contains("manifests") + + // Test Update intent + Builder.fromJson(TEST_MANIFEST_JSON).use { builder2 -> + builder2.setIntent(BuilderIntent.Update) + + val updateSuccess = true // setIntent didn't throw + + val success = editSuccess && updateSuccess + + TestResult( + "Builder Intent Edit and Update", + success, + if (success) { + "Edit and Update intents work" + } else { + "Intent test failed" + }, + "Edit signed: $editSuccess, Update set: $updateSuccess", + ) + } + } + } + } + } finally { + fileTest.delete() + } + } } catch (e: Exception) { TestResult( - "Builder Set Intent", + "Builder Intent Edit and Update", false, "Exception: ${e.message}", e.toString(), @@ -534,8 +907,7 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> // Add multiple actions builder.addAction( Action( @@ -564,42 +936,38 @@ abstract class BuilderTests : TestBase() { val destStream = FileStream(fileTest) try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)) - - try { - builder.sign("image/jpeg", sourceStream, destStream, signer) - - val manifest = C2PA.readFile(fileTest.absolutePath) - val manifestLower = manifest.lowercase() - - val hasEditedAction = manifestLower.contains("c2pa.edited") - val hasCroppedAction = manifestLower.contains("c2pa.cropped") - val hasCustomAction = manifestLower.contains("com.example.custom_action") - - val success = hasEditedAction && hasCroppedAction && hasCustomAction - - TestResult( - "Builder Add Action", - success, - if (success) { - "All actions added successfully" - } else { - "Some actions missing" - }, - "Edited: $hasEditedAction, Cropped: $hasCroppedAction, Custom: $hasCustomAction\nManifest preview: ${manifest.take(500)}...", - ) - } finally { - signer.close() + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val manifestLower = manifest.lowercase() + + val hasEditedAction = manifestLower.contains("c2pa.edited") + val hasCroppedAction = manifestLower.contains("c2pa.cropped") + val hasCustomAction = manifestLower.contains("com.example.custom_action") + + val success = hasEditedAction && hasCroppedAction && hasCustomAction + + TestResult( + "Builder Add Action", + success, + if (success) { + "All actions added successfully" + } else { + "Some actions missing" + }, + "Edited: $hasEditedAction, Cropped: $hasCroppedAction, Custom: $hasCustomAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } } } finally { - sourceStream.close() - destStream.close() fileTest.delete() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt new file mode 100644 index 0000000..21fbc6f --- /dev/null +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt @@ -0,0 +1,1081 @@ +/* +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. +*/ + +package org.contentauth.c2pa.test.shared + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.contentauth.c2pa.manifest.SettingsValidator +import org.contentauth.c2pa.manifest.ValidationResult + +/** Tests for SettingsValidator and ValidationResult. */ +abstract class SettingsValidatorTests : TestBase() { + + companion object { + const val VALID_PEM_CERT = "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----" + const val VALID_PEM_KEY = "-----BEGIN PRIVATE KEY-----\nMIIBtest\n-----END PRIVATE KEY-----" + const val VALID_PEM_EC_KEY = "-----BEGIN EC PRIVATE KEY-----\nMIIBtest\n-----END EC PRIVATE KEY-----" + const val VALID_PEM_RSA_KEY = "-----BEGIN RSA PRIVATE KEY-----\nMIIBtest\n-----END RSA PRIVATE KEY-----" + } + + suspend fun testValidSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Valid Settings") { + val settingsJson = """{"version": 1}""" + val result = SettingsValidator.validate(settingsJson, logWarnings = false) + + val success = result.isValid() && !result.hasErrors() && !result.hasWarnings() + TestResult( + "Valid Settings", + success, + if (success) "Minimal valid settings accepted" else "Unexpected validation failures", + "Errors: ${result.errors}, Warnings: ${result.warnings}", + ) + } + } + + suspend fun testInvalidJson(): TestResult = withContext(Dispatchers.IO) { + runTest("Invalid JSON") { + val result = SettingsValidator.validate("not valid json {{{", logWarnings = false) + + val success = result.hasErrors() && + result.errors.any { it.contains("Failed to parse") } + TestResult( + "Invalid JSON", + success, + if (success) "Malformed JSON correctly rejected" else "Expected parse error", + "Errors: ${result.errors}", + ) + } + } + + suspend fun testMissingVersion(): TestResult = withContext(Dispatchers.IO) { + runTest("Missing Version") { + val result = SettingsValidator.validate("""{}""", logWarnings = false) + + val success = result.hasErrors() && + result.errors.any { it.contains("version") } + TestResult( + "Missing Version", + success, + if (success) "Missing version correctly detected" else "Expected version error", + "Errors: ${result.errors}", + ) + } + } + + suspend fun testWrongVersion(): TestResult = withContext(Dispatchers.IO) { + runTest("Wrong Version") { + val result = SettingsValidator.validate("""{"version": 2}""", logWarnings = false) + + val success = result.hasErrors() && + result.errors.any { it.contains("version") && it.contains("2") } + TestResult( + "Wrong Version", + success, + if (success) "Wrong version correctly rejected" else "Expected version error", + "Errors: ${result.errors}", + ) + } + } + + suspend fun testUnknownTopLevelKeys(): TestResult = withContext(Dispatchers.IO) { + runTest("Unknown Top-Level Keys") { + val settingsJson = """{"version": 1, "unknown_section": {}, "another_unknown": true}""" + val result = SettingsValidator.validate(settingsJson, logWarnings = false) + + val success = !result.hasErrors() && + result.hasWarnings() && + result.warnings.any { it.contains("unknown_section") } && + result.warnings.any { it.contains("another_unknown") } + TestResult( + "Unknown Top-Level Keys", + success, + if (success) "Unknown keys produce warnings" else "Expected warnings for unknown keys", + "Warnings: ${result.warnings}", + ) + } + } + + suspend fun testTrustSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Trust Section Validation") { + val errors = mutableListOf() + + // Valid trust section with PEM certificates + val validTrust = """{ + "version": 1, + "trust": { + "trust_anchors": "$VALID_PEM_CERT" + } + }""" + val validResult = SettingsValidator.validate(validTrust, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid trust section rejected: ${validResult.errors}") + } + + // Invalid PEM format + val invalidPem = """{ + "version": 1, + "trust": { + "trust_anchors": "not a PEM certificate" + } + }""" + val invalidResult = SettingsValidator.validate(invalidPem, logWarnings = false) + if (!invalidResult.hasErrors()) { + errors.add("Invalid PEM not detected") + } + + // Unknown key in trust + val unknownKey = """{ + "version": 1, + "trust": { + "unknown_trust_key": true + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown trust key did not produce warning") + } + + // user_anchors and allowed_list PEM validation + val multiPem = """{ + "version": 1, + "trust": { + "user_anchors": "$VALID_PEM_CERT", + "allowed_list": "not valid" + } + }""" + val multiResult = SettingsValidator.validate(multiPem, logWarnings = false) + if (!multiResult.hasErrors() || !multiResult.errors.any { it.contains("allowed_list") }) { + errors.add("Invalid allowed_list PEM not detected") + } + + val success = errors.isEmpty() + TestResult( + "Trust Section Validation", + success, + if (success) "Trust section validated correctly" else "Trust validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testCawgTrustSection(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWG Trust Section Validation") { + val errors = mutableListOf() + + // Valid cawg_trust with verify_trust_list boolean + val validCawg = """{ + "version": 1, + "cawg_trust": { + "verify_trust_list": true + } + }""" + val validResult = SettingsValidator.validate(validCawg, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid cawg_trust rejected: ${validResult.errors}") + } + + // Invalid verify_trust_list type + val invalidType = """{ + "version": 1, + "cawg_trust": { + "verify_trust_list": "not_a_boolean" + } + }""" + val invalidResult = SettingsValidator.validate(invalidType, logWarnings = false) + if (!invalidResult.hasErrors()) { + errors.add("Non-boolean verify_trust_list not detected") + } + + val success = errors.isEmpty() + TestResult( + "CAWG Trust Section Validation", + success, + if (success) "CAWG trust section validated correctly" else "CAWG trust validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testCoreSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Core Section Validation") { + val errors = mutableListOf() + + // Valid core section + val validCore = """{ + "version": 1, + "core": { + "merkle_tree_chunk_size_in_kb": 64, + "merkle_tree_max_proofs": 128, + "backing_store_memory_threshold_in_mb": 256, + "decode_identity_assertions": true, + "allowed_network_hosts": ["example.com"] + } + }""" + val validResult = SettingsValidator.validate(validCore, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid core section rejected: ${validResult.errors}") + } + + // Invalid numeric field + val invalidNumeric = """{ + "version": 1, + "core": { + "merkle_tree_chunk_size_in_kb": "not_a_number" + } + }""" + val numResult = SettingsValidator.validate(invalidNumeric, logWarnings = false) + if (!numResult.hasErrors()) { + errors.add("Non-numeric merkle_tree_chunk_size_in_kb not detected") + } + + // Invalid boolean field + val invalidBool = """{ + "version": 1, + "core": { + "decode_identity_assertions": "yes" + } + }""" + val boolResult = SettingsValidator.validate(invalidBool, logWarnings = false) + if (!boolResult.hasErrors()) { + errors.add("Non-boolean decode_identity_assertions not detected") + } + + // Invalid array field + val invalidArray = """{ + "version": 1, + "core": { + "allowed_network_hosts": "not_an_array" + } + }""" + val arrayResult = SettingsValidator.validate(invalidArray, logWarnings = false) + if (!arrayResult.hasErrors()) { + errors.add("Non-array allowed_network_hosts not detected") + } + + // Unknown key produces warning + val unknownKey = """{ + "version": 1, + "core": { + "unknown_core_key": 42 + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown core key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Core Section Validation", + success, + if (success) "Core section validated correctly" else "Core validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testVerifySection(): TestResult = withContext(Dispatchers.IO) { + runTest("Verify Section Validation") { + val errors = mutableListOf() + + // Valid verify section + val validVerify = """{ + "version": 1, + "verify": { + "verify_after_reading": true, + "verify_after_sign": true, + "verify_trust": true, + "verify_timestamp_trust": true, + "ocsp_fetch": false, + "remote_manifest_fetch": true, + "skip_ingredient_conflict_resolution": false, + "strict_v1_validation": false + } + }""" + val validResult = SettingsValidator.validate(validVerify, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid verify section rejected: ${validResult.errors}") + } + + // Non-boolean verify field + val invalidBool = """{ + "version": 1, + "verify": { + "verify_trust": "yes" + } + }""" + val boolResult = SettingsValidator.validate(invalidBool, logWarnings = false) + if (!boolResult.hasErrors()) { + errors.add("Non-boolean verify_trust not detected") + } + + // Disabled verification produces warnings + val disabledVerify = """{ + "version": 1, + "verify": { + "verify_trust": false, + "verify_timestamp_trust": false, + "verify_after_sign": false + } + }""" + val warnResult = SettingsValidator.validate(disabledVerify, logWarnings = false) + if (!warnResult.hasWarnings() || warnResult.warnings.size < 3) { + errors.add("Expected 3 warnings for disabled verification, got ${warnResult.warnings.size}") + } + + // Unknown verify key + val unknownKey = """{ + "version": 1, + "verify": { + "unknown_verify_key": true + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown verify key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Verify Section Validation", + success, + if (success) "Verify section validated correctly" else "Verify validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testBuilderSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder Section Validation") { + val errors = mutableListOf() + + // Valid intent as string + val editIntent = """{ + "version": 1, + "builder": { + "intent": "Edit" + } + }""" + val editResult = SettingsValidator.validate(editIntent, logWarnings = false) + if (editResult.hasErrors()) { + errors.add("Valid Edit intent rejected: ${editResult.errors}") + } + + // Valid intent as object + val createIntent = """{ + "version": 1, + "builder": { + "intent": {"Create": "digitalCapture"} + } + }""" + val createResult = SettingsValidator.validate(createIntent, logWarnings = false) + if (createResult.hasErrors()) { + errors.add("Valid Create intent rejected: ${createResult.errors}") + } + + // Invalid intent string + val badIntent = """{ + "version": 1, + "builder": { + "intent": "Delete" + } + }""" + val badIntentResult = SettingsValidator.validate(badIntent, logWarnings = false) + if (!badIntentResult.hasErrors()) { + errors.add("Invalid intent string 'Delete' not detected") + } + + // Invalid intent object (missing Create key) + val badObj = """{ + "version": 1, + "builder": { + "intent": {"NotCreate": "digitalCapture"} + } + }""" + val badObjResult = SettingsValidator.validate(badObj, logWarnings = false) + if (!badObjResult.hasErrors()) { + errors.add("Intent object without Create key not detected") + } + + // Invalid intent Create source type + val badSource = """{ + "version": 1, + "builder": { + "intent": {"Create": "invalidSourceType"} + } + }""" + val badSourceResult = SettingsValidator.validate(badSource, logWarnings = false) + if (!badSourceResult.hasErrors()) { + errors.add("Invalid Create source type not detected") + } + + // claim_generator_info without name + val noName = """{ + "version": 1, + "builder": { + "claim_generator_info": {"version": "1.0"} + } + }""" + val noNameResult = SettingsValidator.validate(noName, logWarnings = false) + if (!noNameResult.hasErrors()) { + errors.add("claim_generator_info without name not detected") + } + + // created_assertion_labels not an array + val badLabels = """{ + "version": 1, + "builder": { + "created_assertion_labels": "not_an_array" + } + }""" + val labelsResult = SettingsValidator.validate(badLabels, logWarnings = false) + if (!labelsResult.hasErrors()) { + errors.add("Non-array created_assertion_labels not detected") + } + + // generate_c2pa_archive not boolean + val badArchive = """{ + "version": 1, + "builder": { + "generate_c2pa_archive": "yes" + } + }""" + val archiveResult = SettingsValidator.validate(badArchive, logWarnings = false) + if (!archiveResult.hasErrors()) { + errors.add("Non-boolean generate_c2pa_archive not detected") + } + + // Unknown builder key + val unknownKey = """{ + "version": 1, + "builder": { + "unknown_builder_key": true + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown builder key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Builder Section Validation", + success, + if (success) "Builder section validated correctly" else "Builder validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testThumbnailSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Thumbnail Section Validation") { + val errors = mutableListOf() + + // Valid thumbnail + val validThumb = """{ + "version": 1, + "builder": { + "thumbnail": { + "enabled": true, + "format": "jpeg", + "quality": "medium", + "long_edge": 1024, + "ignore_errors": false, + "prefer_smallest_format": true + } + } + }""" + val validResult = SettingsValidator.validate(validThumb, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid thumbnail rejected: ${validResult.errors}") + } + + // Invalid format + val badFormat = """{ + "version": 1, + "builder": { + "thumbnail": {"format": "bmp"} + } + }""" + val formatResult = SettingsValidator.validate(badFormat, logWarnings = false) + if (!formatResult.hasErrors()) { + errors.add("Invalid thumbnail format 'bmp' not detected") + } + + // Invalid quality + val badQuality = """{ + "version": 1, + "builder": { + "thumbnail": {"quality": "ultra"} + } + }""" + val qualityResult = SettingsValidator.validate(badQuality, logWarnings = false) + if (!qualityResult.hasErrors()) { + errors.add("Invalid thumbnail quality 'ultra' not detected") + } + + // Invalid long_edge type + val badEdge = """{ + "version": 1, + "builder": { + "thumbnail": {"long_edge": "big"} + } + }""" + val edgeResult = SettingsValidator.validate(badEdge, logWarnings = false) + if (!edgeResult.hasErrors()) { + errors.add("Non-numeric long_edge not detected") + } + + // Invalid boolean field + val badBool = """{ + "version": 1, + "builder": { + "thumbnail": {"enabled": "yes"} + } + }""" + val boolResult = SettingsValidator.validate(badBool, logWarnings = false) + if (!boolResult.hasErrors()) { + errors.add("Non-boolean thumbnail.enabled not detected") + } + + // Unknown thumbnail key + val unknownKey = """{ + "version": 1, + "builder": { + "thumbnail": {"unknown_thumb_key": true} + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown thumbnail key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Thumbnail Section Validation", + success, + if (success) "Thumbnail section validated correctly" else "Thumbnail validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testActionsSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Actions Section Validation") { + val errors = mutableListOf() + + // Valid actions with auto actions + val validActions = """{ + "version": 1, + "builder": { + "actions": { + "auto_created_action": { + "enabled": true, + "source_type": "digitalCapture" + } + } + } + }""" + val validResult = SettingsValidator.validate(validActions, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid actions section rejected: ${validResult.errors}") + } + + // Invalid source_type in auto action + val badSource = """{ + "version": 1, + "builder": { + "actions": { + "auto_opened_action": { + "source_type": "invalidType" + } + } + } + }""" + val sourceResult = SettingsValidator.validate(badSource, logWarnings = false) + if (!sourceResult.hasErrors()) { + errors.add("Invalid auto action source_type not detected") + } + + // Invalid enabled type in auto action + val badEnabled = """{ + "version": 1, + "builder": { + "actions": { + "auto_placed_action": { + "enabled": "yes" + } + } + } + }""" + val enabledResult = SettingsValidator.validate(badEnabled, logWarnings = false) + if (!enabledResult.hasErrors()) { + errors.add("Non-boolean auto action enabled not detected") + } + + // Unknown key in actions section + val unknownKey = """{ + "version": 1, + "builder": { + "actions": { + "unknown_action_key": true + } + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown action key did not produce warning") + } + + // Unknown key in auto action + val unknownAutoKey = """{ + "version": 1, + "builder": { + "actions": { + "auto_created_action": { + "unknown_auto_key": true + } + } + } + }""" + val unknownAutoResult = SettingsValidator.validate(unknownAutoKey, logWarnings = false) + if (!unknownAutoResult.hasWarnings()) { + errors.add("Unknown auto action key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Actions Section Validation", + success, + if (success) "Actions section validated correctly" else "Actions validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testLocalSigner(): TestResult = withContext(Dispatchers.IO) { + runTest("Local Signer Validation") { + val errors = mutableListOf() + + // Valid local signer + val validLocal = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY", + "tsa_url": "https://timestamp.example.com" + } + } + }""" + val validResult = SettingsValidator.validate(validLocal, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid local signer rejected: ${validResult.errors}") + } + + // Missing required fields + val missingFields = """{ + "version": 1, + "signer": { + "local": {} + } + }""" + val missingResult = SettingsValidator.validate(missingFields, logWarnings = false) + if (!missingResult.hasErrors() || missingResult.errors.size < 3) { + errors.add("Expected 3+ errors for missing local signer fields, got ${missingResult.errors.size}") + } + + // Invalid algorithm + val badAlg = """{ + "version": 1, + "signer": { + "local": { + "alg": "invalid_alg", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY" + } + } + }""" + val algResult = SettingsValidator.validate(badAlg, logWarnings = false) + if (!algResult.hasErrors()) { + errors.add("Invalid algorithm not detected") + } + + // Invalid certificate PEM + val badCert = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "not a cert", + "private_key": "$VALID_PEM_KEY" + } + } + }""" + val certResult = SettingsValidator.validate(badCert, logWarnings = false) + if (!certResult.hasErrors()) { + errors.add("Invalid certificate PEM not detected") + } + + // Invalid private key PEM + val badKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "not a key" + } + } + }""" + val keyResult = SettingsValidator.validate(badKey, logWarnings = false) + if (!keyResult.hasErrors()) { + errors.add("Invalid private key PEM not detected") + } + + // EC PRIVATE KEY format accepted + val ecKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_EC_KEY" + } + } + }""" + val ecResult = SettingsValidator.validate(ecKey, logWarnings = false) + if (ecResult.errors.any { it.contains("private_key") }) { + errors.add("EC PRIVATE KEY format rejected: ${ecResult.errors}") + } + + // RSA PRIVATE KEY format accepted + val rsaKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "ps256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_RSA_KEY" + } + } + }""" + val rsaResult = SettingsValidator.validate(rsaKey, logWarnings = false) + if (rsaResult.errors.any { it.contains("private_key") }) { + errors.add("RSA PRIVATE KEY format rejected: ${rsaResult.errors}") + } + + // Invalid TSA URL + val badTsa = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY", + "tsa_url": "ftp://not-http" + } + } + }""" + val tsaResult = SettingsValidator.validate(badTsa, logWarnings = false) + if (!tsaResult.hasErrors()) { + errors.add("Invalid TSA URL (ftp) not detected") + } + + // Unknown key in local signer + val unknownKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY", + "unknown_local_key": true + } + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown local signer key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Local Signer Validation", + success, + if (success) "Local signer validated correctly" else "Local signer validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testRemoteSigner(): TestResult = withContext(Dispatchers.IO) { + runTest("Remote Signer Validation") { + val errors = mutableListOf() + + // Valid remote signer + val validRemote = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com/sign", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "tsa_url": "https://timestamp.example.com" + } + } + }""" + val validResult = SettingsValidator.validate(validRemote, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid remote signer rejected: ${validResult.errors}") + } + + // Missing required fields + val missingFields = """{ + "version": 1, + "signer": { + "remote": {} + } + }""" + val missingResult = SettingsValidator.validate(missingFields, logWarnings = false) + if (!missingResult.hasErrors() || missingResult.errors.size < 3) { + errors.add("Expected 3+ errors for missing remote signer fields, got ${missingResult.errors.size}") + } + + // Invalid URL + val badUrl = """{ + "version": 1, + "signer": { + "remote": { + "url": "not_a_url", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT" + } + } + }""" + val urlResult = SettingsValidator.validate(badUrl, logWarnings = false) + if (!urlResult.hasErrors()) { + errors.add("Invalid URL not detected") + } + + // Invalid algorithm + val badAlg = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "invalid", + "sign_cert": "$VALID_PEM_CERT" + } + } + }""" + val algResult = SettingsValidator.validate(badAlg, logWarnings = false) + if (!algResult.hasErrors()) { + errors.add("Invalid remote algorithm not detected") + } + + // Invalid certificate PEM + val badCert = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "not a cert" + } + } + }""" + val certResult = SettingsValidator.validate(badCert, logWarnings = false) + if (!certResult.hasErrors()) { + errors.add("Invalid remote certificate PEM not detected") + } + + // Invalid TSA URL + val badTsa = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "tsa_url": "ftp://invalid" + } + } + }""" + val tsaResult = SettingsValidator.validate(badTsa, logWarnings = false) + if (!tsaResult.hasErrors()) { + errors.add("Invalid remote TSA URL not detected") + } + + // Unknown key in remote signer + val unknownKey = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "unknown_remote_key": true + } + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown remote signer key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Remote Signer Validation", + success, + if (success) "Remote signer validated correctly" else "Remote signer validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testSignerMutualExclusion(): TestResult = withContext(Dispatchers.IO) { + runTest("Signer Mutual Exclusion") { + val errors = mutableListOf() + + // Both local and remote + val bothSigners = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY" + }, + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT" + } + } + }""" + val bothResult = SettingsValidator.validate(bothSigners, logWarnings = false) + if (!bothResult.hasErrors() || !bothResult.errors.any { it.contains("both") }) { + errors.add("Both local+remote signer not detected") + } + + // Neither local nor remote + val neitherSigner = """{ + "version": 1, + "signer": {} + }""" + val neitherResult = SettingsValidator.validate(neitherSigner, logWarnings = false) + if (!neitherResult.hasErrors() || !neitherResult.errors.any { it.contains("either") }) { + errors.add("Missing local/remote signer not detected") + } + + // cawg_x509_signer also validates + val cawgSigner = """{ + "version": 1, + "cawg_x509_signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY" + } + } + }""" + val cawgResult = SettingsValidator.validate(cawgSigner, logWarnings = false) + if (cawgResult.hasErrors()) { + errors.add("Valid cawg_x509_signer rejected: ${cawgResult.errors}") + } + + val success = errors.isEmpty() + TestResult( + "Signer Mutual Exclusion", + success, + if (success) "Signer exclusion rules validated correctly" else "Signer exclusion failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testValidationResultHelpers(): TestResult = withContext(Dispatchers.IO) { + runTest("ValidationResult Helpers") { + val errors = mutableListOf() + + // Empty result + val empty = ValidationResult() + if (empty.hasErrors()) errors.add("Empty result reports hasErrors") + if (empty.hasWarnings()) errors.add("Empty result reports hasWarnings") + if (!empty.isValid()) errors.add("Empty result reports not valid") + + // With errors + val withErrors = ValidationResult(errors = listOf("An error")) + if (!withErrors.hasErrors()) errors.add("Result with errors reports no errors") + if (withErrors.hasWarnings()) errors.add("Result with only errors reports warnings") + if (withErrors.isValid()) errors.add("Result with errors reports valid") + + // With warnings only + val withWarnings = ValidationResult(warnings = listOf("A warning")) + if (withWarnings.hasErrors()) errors.add("Result with only warnings reports errors") + if (!withWarnings.hasWarnings()) errors.add("Result with warnings reports no warnings") + if (!withWarnings.isValid()) errors.add("Result with only warnings reports not valid") + + // With both + val withBoth = ValidationResult( + errors = listOf("Error"), + warnings = listOf("Warning"), + ) + if (!withBoth.hasErrors()) errors.add("Result with both reports no errors") + if (!withBoth.hasWarnings()) errors.add("Result with both reports no warnings") + if (withBoth.isValid()) errors.add("Result with both reports valid") + + val success = errors.isEmpty() + TestResult( + "ValidationResult Helpers", + success, + if (success) "All ValidationResult helpers work correctly" else "ValidationResult helper failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testValidateAndLog(): TestResult = withContext(Dispatchers.IO) { + runTest("Validate and Log") { + // validateAndLog should work the same as validate with logWarnings=true + val result = SettingsValidator.validateAndLog("""{"version": 1}""") + val success = result.isValid() + TestResult( + "Validate and Log", + success, + if (success) "validateAndLog works correctly" else "validateAndLog failed", + "Errors: ${result.errors}, Warnings: ${result.warnings}", + ) + } + } + + suspend fun testIntentAsNumber(): TestResult = withContext(Dispatchers.IO) { + runTest("Intent As Number") { + // Intent as a number (neither string nor object) + val result = SettingsValidator.validate( + """{"version": 1, "builder": {"intent": 42}}""", + logWarnings = false, + ) + val success = result.hasErrors() && + result.errors.any { it.contains("intent") } + TestResult( + "Intent As Number", + success, + if (success) "Non-string/object intent correctly rejected" else "Expected intent type error", + "Errors: ${result.errors}", + ) + } + } +} diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt index e12ded6..4fac7fe 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt @@ -253,6 +253,151 @@ abstract class StreamTests : TestBase() { } } + suspend fun testCallbackStreamFactories(): TestResult = withContext(Dispatchers.IO) { + runTest("Callback Stream Factories") { + val errors = mutableListOf() + + // forReading factory + CallbackStream.forReading( + reader = { _, _ -> 0 }, + seeker = { _, _ -> 0L }, + ).use { stream -> + // Should support read and seek + stream.read(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + // Should throw on write + try { + stream.write(ByteArray(1), 1) + errors.add("forReading should not support write") + } catch (e: UnsupportedOperationException) { + // expected + } + // Should throw on flush + try { + stream.flush() + errors.add("forReading should not support flush") + } catch (e: UnsupportedOperationException) { + // expected + } + } + + // forWriting factory + CallbackStream.forWriting( + writer = { _, length -> length }, + seeker = { _, _ -> 0L }, + flusher = { 0 }, + ).use { stream -> + // Should support write, seek, flush + stream.write(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + stream.flush() + // Should throw on read + try { + stream.read(ByteArray(1), 1) + errors.add("forWriting should not support read") + } catch (e: UnsupportedOperationException) { + // expected + } + } + + // forReadWrite factory + CallbackStream.forReadWrite( + reader = { _, _ -> 0 }, + writer = { _, length -> length }, + seeker = { _, _ -> 0L }, + flusher = { 0 }, + ).use { stream -> + // Should support all operations + stream.read(ByteArray(1), 1) + stream.write(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + stream.flush() + } + + val success = errors.isEmpty() + TestResult( + "Callback Stream Factories", + success, + if (success) "All factory methods work correctly" else "Factory method failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testByteArrayStreamBufferGrowth(): TestResult = withContext(Dispatchers.IO) { + runTest("ByteArrayStream Buffer Growth") { + val errors = mutableListOf() + + // Start with empty stream + val stream = ByteArrayStream() + stream.use { + // Write data to trigger buffer growth + val data1 = ByteArray(100) { 0xAA.toByte() } + it.write(data1, 100) + + // Verify position and data + var result = it.getData() + if (result.size != 100) { + errors.add("After first write: expected size 100, got ${result.size}") + } + + // Write more to trigger growth + val data2 = ByteArray(200) { 0xBB.toByte() } + it.write(data2, 200) + + result = it.getData() + if (result.size != 300) { + errors.add("After second write: expected size 300, got ${result.size}") + } + + // Seek back and verify read + it.seek(0, SeekMode.START.value) + val readBuf = ByteArray(100) + val bytesRead = it.read(readBuf, 100) + if (bytesRead != 100L) { + errors.add("Read returned $bytesRead instead of 100") + } + if (readBuf[0] != 0xAA.toByte()) { + errors.add("Read data mismatch at position 0") + } + + // Seek to middle and overwrite + it.seek(50, SeekMode.START.value) + val data3 = ByteArray(10) { 0xCC.toByte() } + it.write(data3, 10) + + // Size should not change (overwrite within existing bounds) + result = it.getData() + if (result.size != 300) { + errors.add("After overwrite: expected size 300, got ${result.size}") + } + if (result[50] != 0xCC.toByte()) { + errors.add("Overwrite data mismatch at position 50") + } + + // Seek to end and verify + val endPos = it.seek(0, SeekMode.END.value) + if (endPos != 300L) { + errors.add("Seek to end returned $endPos instead of 300") + } + + // Read at end should return 0 + val endRead = it.read(ByteArray(10), 10) + if (endRead != 0L) { + errors.add("Read at end returned $endRead instead of 0") + } + } + + val success = errors.isEmpty() + TestResult( + "ByteArrayStream Buffer Growth", + success, + if (success) "Buffer growth and operations work correctly" else "Buffer growth failures", + errors.joinToString("\n"), + ) + } + } + suspend fun testLargeBufferHandling(): TestResult = withContext(Dispatchers.IO) { runTest("Large Buffer Handling") { val largeSize = Int.MAX_VALUE.toLong() + 1L diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt index 0c4a22e..e1a4a83 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt @@ -1,4 +1,4 @@ -/* +/* 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. @@ -23,12 +23,22 @@ import java.io.File */ abstract class TestBase { + /** Status of an individual test execution. */ enum class TestStatus { PASSED, FAILED, SKIPPED, } + /** + * Result of a single test execution. + * + * @property name The test name. + * @property success Whether the test passed. + * @property message A human-readable summary of the outcome. + * @property details Optional additional details (e.g., stack traces, data dumps). + * @property status The test status, derived from [success] by default. + */ data class TestResult( val name: String, val success: Boolean, @@ -38,10 +48,23 @@ abstract class TestBase { ) companion object { + // Note: C2PA 2.3 spec requires first action to be "c2pa.created" or "c2pa.opened" const val TEST_MANIFEST_JSON = """{ "claim_generator": "test_app/1.0", - "assertions": [{"label": "c2pa.test", "data": {"test": true}}] + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" + } + ] + } + } + ] }""" /** Load a test resource from the classpath (test-shared module resources). */ From 1405f38559983ad01064657e6b016a4d97b66be5 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Tue, 24 Feb 2026 19:35:40 +0100 Subject: [PATCH 2/2] Add manifest validation and spec compliance Add ManifestValidator for C2PA 2.3 spec compliance checking. Enhance ManifestDefinition with created vs gathered assertion separation. Add PredefinedAction, StandardAssertionLabel, and ImageRegionType types. Integrate manifest validation into Builder.fromJson(). Add c2patool integration for external validation. Expand test coverage with comprehensive manifest and validation tests. --- .../contentauth/c2pa/AndroidManifestTests.kt | 134 ++ .../contentauth/c2pa/ResourceTestHelper.kt | 10 + .../kotlin/org/contentauth/c2pa/Action.kt | 65 +- .../kotlin/org/contentauth/c2pa/Builder.kt | 37 +- .../kotlin/org/contentauth/c2pa/Intent.kt | 40 + .../org/contentauth/c2pa/PredefinedAction.kt | 62 + .../c2pa/manifest/ActionAssertion.kt | 82 +- .../c2pa/manifest/AssertionDefinition.kt | 121 +- .../c2pa/manifest/ImageRegionType.kt | 21 + .../contentauth/c2pa/manifest/Ingredient.kt | 40 +- .../c2pa/manifest/ManifestDefinition.kt | 99 +- .../c2pa/manifest/ManifestValidator.kt | 266 +++ .../c2pa/manifest/RegionOfInterest.kt | 19 - .../org/contentauth/c2pa/manifest/Role.kt | 34 +- .../c2pa/manifest/StandardAssertionLabel.kt | 151 +- .../org/contentauth/c2pa/manifest/Time.kt | 3 + .../org/contentauth/c2pa/manifest/TimeType.kt | 4 + .../c2pa/manifest/ValidationStatusCode.kt | 433 ++++- .../contentauth/c2pa/testapp/TestScreen.kt | 5 + .../c2pa/test/shared/BuilderTests.kt | 5 +- .../c2pa/test/shared/ManifestTests.kt | 1683 ++++++++++++++++- 21 files changed, 2974 insertions(+), 340 deletions(-) create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestValidator.kt diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestTests.kt index 668eb67..9ef44de 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestTests.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestTests.kt @@ -9,6 +9,7 @@ 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. */ + package org.contentauth.c2pa import android.content.Context @@ -151,4 +152,137 @@ class AndroidManifestTests : ManifestTests() { val result = testAllDigitalSourceTypes() assertTrue(result.success, "All Digital Source Types test failed: ${result.message}") } + + @Test + fun runTestManifestValidator() = runBlocking { + val result = testManifestValidator() + assertTrue(result.success, "Manifest Validator test failed: ${result.message}") + } + + @Test + fun runTestMixedAssertionTypes() = runBlocking { + val result = testMixedAssertionTypes() + assertTrue(result.success, "Mixed Assertion Types test failed: ${result.message}") + } + + @Test + fun runTestDeprecatedAssertionValidation() = runBlocking { + val result = testDeprecatedAssertionValidation() + assertTrue(result.success, "Deprecated Assertion Validation test failed: ${result.message}") + } + + @Test + fun runTestAllPredefinedActions() = runBlocking { + val result = testAllPredefinedActions() + assertTrue(result.success, "All Predefined Actions test failed: ${result.message}") + } + + @Test + fun runTestAllIngredientRelationships() = runBlocking { + val result = testAllIngredientRelationships() + assertTrue(result.success, "All Ingredient Relationships test failed: ${result.message}") + } + + @Test + fun runTestRedactions() = runBlocking { + val result = testRedactions() + assertTrue(result.success, "Redactions test failed: ${result.message}") + } + + + @Test + fun runTestCawgTrainingMiningAssertion() = runBlocking { + val result = testCawgTrainingMiningAssertion() + assertTrue(result.success, "CAWG Training Mining Assertion test failed: ${result.message}") + } + + @Test + fun runTestEditedFactory() = runBlocking { + val result = testEditedFactory() + assertTrue(result.success, "Edited Factory test failed: ${result.message}") + } + + @Test + fun runTestAssertionsWithBuilder() = runBlocking { + val result = testAssertionsWithBuilder() + assertTrue(result.success, "Assertions with Builder test failed: ${result.message}") + } + + @Test + fun runTestCustomGatheredAssertionWithBuilder() = runBlocking { + val result = testCustomGatheredAssertionWithBuilder() + assertTrue(result.success, "Custom Gathered Assertion with Builder test failed: ${result.message}") + } + + @Test + fun runTestSettingsValidatorValid() = runBlocking { + val result = testSettingsValidatorValid() + assertTrue(result.success, "Settings Validator Valid test failed: ${result.message}") + } + + @Test + fun runTestSettingsValidatorErrors() = runBlocking { + val result = testSettingsValidatorErrors() + assertTrue(result.success, "Settings Validator Errors test failed: ${result.message}") + } + + @Test + fun runTestSettingsValidatorBuilderSection() = runBlocking { + val result = testSettingsValidatorBuilderSection() + assertTrue(result.success, "Settings Validator Builder Section test failed: ${result.message}") + } + + @Test + fun runTestSettingsValidatorSignerSection() = runBlocking { + val result = testSettingsValidatorSignerSection() + assertTrue(result.success, "Settings Validator Signer Section test failed: ${result.message}") + } + + @Test + fun runTestManifestValidatorDeprecatedAssertions() = runBlocking { + val result = testManifestValidatorDeprecatedAssertions() + assertTrue(result.success, "Manifest Validator Deprecated Assertions test failed: ${result.message}") + } + + @Test + fun runTestDigitalSourceTypeFromIptcUrl() = runBlocking { + val result = testDigitalSourceTypeFromIptcUrl() + assertTrue(result.success, "DigitalSourceType fromIptcUrl test failed: ${result.message}") + } + + @Test + fun runTestManifestAssertionLabels() = runBlocking { + val result = testManifestAssertionLabels() + assertTrue(result.success, "ManifestDefinition assertionLabels test failed: ${result.message}") + } + + @Test + fun runTestManifestToPrettyJson() = runBlocking { + val result = testManifestToPrettyJson() + assertTrue(result.success, "ManifestDefinition toPrettyJson test failed: ${result.message}") + } + + @Test + fun runTestIptcPhotoMetadata() = runBlocking { + val result = testIptcPhotoMetadata() + assertTrue(result.success, "IptcPhotoMetadata test failed: ${result.message}") + } + + @Test + fun runTestCustomAssertionLabelValidation() = runBlocking { + val result = testCustomAssertionLabelValidation() + assertTrue(result.success, "Custom Assertion Label Validation test failed: ${result.message}") + } + + @Test + fun runTestImageRegionTypeToTypeString() = runBlocking { + val result = testImageRegionTypeToTypeString() + assertTrue(result.success, "ImageRegionType toTypeString test failed: ${result.message}") + } + + @Test + fun runTestStandardAssertionLabelSerialNames() = runBlocking { + val result = testStandardAssertionLabelSerialNames() + assertTrue(result.success, "StandardAssertionLabel serialNames test failed: ${result.message}") + } } diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/ResourceTestHelper.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/ResourceTestHelper.kt index 651d49c..1cb9289 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/ResourceTestHelper.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/ResourceTestHelper.kt @@ -9,14 +9,22 @@ 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. */ + package org.contentauth.c2pa import android.content.Context import org.contentauth.c2pa.test.shared.TestBase import java.io.File +/** + * Helper object for loading test resources in Android instrumented tests. + * + * Resolves resource names by trying common file extensions (`.jpg`, `.pem`, `.key`, `.toml`, + * `.json`) against the shared test-resource classpath. + */ object ResourceTestHelper { + /** Loads a test resource as a [ByteArray], trying common file extensions. */ fun loadResourceAsBytes(resourceName: String): ByteArray { val sharedResource = TestBase.loadSharedResourceAsBytes("$resourceName.jpg") @@ -28,6 +36,7 @@ object ResourceTestHelper { return sharedResource ?: throw IllegalArgumentException("Resource not found: $resourceName") } + /** Loads a test resource as a [String], trying common file extensions. */ fun loadResourceAsString(resourceName: String): String { val sharedResource = TestBase.loadSharedResourceAsString("$resourceName.jpg") @@ -39,6 +48,7 @@ object ResourceTestHelper { return sharedResource ?: throw IllegalArgumentException("Resource not found: $resourceName") } + /** Copies a test resource to a [File] in the given [context]'s files directory. */ fun copyResourceToFile(context: Context, resourceName: String, fileName: String): File { val file = File(context.filesDir, fileName) val resourceBytes = loadResourceAsBytes(resourceName) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Action.kt b/library/src/main/kotlin/org/contentauth/c2pa/Action.kt index 03d66da..9d1e939 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Action.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Action.kt @@ -12,18 +12,24 @@ each license. package org.contentauth.c2pa -import org.json.JSONObject +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Represents a C2PA action that describes an operation performed on content. * * Actions are used to document the editing history of an asset, such as cropping, filtering, or - * color adjustments. + * color adjustments. This class is used with the imperative [Builder.addAction] API. + * + * In C2PA v2, `softwareAgent` may be either a plain string (v1 format) or a + * `generator-info-map` object (v2 format), represented here as [JsonElement]. * * @property action The action name. Use [PredefinedAction] values or custom action strings. * @property digitalSourceType A URL identifying an IPTC digital source type. Use * [DigitalSourceType] values or custom URLs. - * @property softwareAgent The software or hardware used to perform the action. + * @property softwareAgent The software or hardware used to perform the action (string or object). * @property parameters Additional information describing the action. * @see Builder.addAction * @see PredefinedAction @@ -31,8 +37,8 @@ import org.json.JSONObject data class Action( val action: String, val digitalSourceType: String? = null, - val softwareAgent: String? = null, - val parameters: Map? = null, + val softwareAgent: JsonElement? = null, + val parameters: Map? = null, ) { /** * Creates an action using a [PredefinedAction] and [DigitalSourceType]. @@ -46,11 +52,11 @@ data class Action( action: PredefinedAction, digitalSourceType: DigitalSourceType, softwareAgent: String? = null, - parameters: Map? = null, + parameters: Map? = null, ) : this( action = action.value, digitalSourceType = digitalSourceType.toIptcUrl(), - softwareAgent = softwareAgent, + softwareAgent = softwareAgent?.let { JsonPrimitive(it) }, parameters = parameters, ) @@ -64,47 +70,22 @@ data class Action( constructor( action: PredefinedAction, softwareAgent: String? = null, - parameters: Map? = null, + parameters: Map? = null, ) : this( action = action.value, digitalSourceType = null, - softwareAgent = softwareAgent, + softwareAgent = softwareAgent?.let { JsonPrimitive(it) }, parameters = parameters, ) - internal fun toJson(): String { - val json = JSONObject() - json.put("action", action) - digitalSourceType?.let { json.put("digitalSourceType", it) } - softwareAgent?.let { json.put("softwareAgent", it) } + internal fun toJson(): String = buildJsonObject { + put("action", action) + digitalSourceType?.let { put("digitalSourceType", it) } + softwareAgent?.let { put("softwareAgent", it) } parameters?.let { params -> - val paramsJson = JSONObject() - params.forEach { (key, value) -> paramsJson.put(key, value) } - json.put("parameters", paramsJson) + put("parameters", buildJsonObject { + params.forEach { (key, value) -> put(key, value) } + }) } - return json.toString() - } + }.toString() } - -private fun DigitalSourceType.toIptcUrl(): String = - when (this) { - DigitalSourceType.EMPTY -> "http://c2pa.org/digitalsourcetype/empty" - DigitalSourceType.TRAINED_ALGORITHMIC_DATA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicData" - DigitalSourceType.DIGITAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" - DigitalSourceType.COMPUTATIONAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" - DigitalSourceType.NEGATIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/negativeFilm" - DigitalSourceType.POSITIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/positiveFilm" - DigitalSourceType.PRINT -> "http://cv.iptc.org/newscodes/digitalsourcetype/print" - DigitalSourceType.HUMAN_EDITS -> "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" - DigitalSourceType.COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - DigitalSourceType.ALGORITHMICALLY_ENHANCED -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced" - DigitalSourceType.DIGITAL_CREATION -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - DigitalSourceType.DATA_DRIVEN_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/dataDrivenMedia" - DigitalSourceType.TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" - DigitalSourceType.ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia" - DigitalSourceType.SCREEN_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/screenCapture" - DigitalSourceType.VIRTUAL_RECORDING -> "http://cv.iptc.org/newscodes/digitalsourcetype/virtualRecording" - DigitalSourceType.COMPOSITE -> "http://cv.iptc.org/newscodes/digitalsourcetype/composite" - DigitalSourceType.COMPOSITE_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture" - DigitalSourceType.COMPOSITE_SYNTHETIC -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic" - } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt index 078d130..fb152ba 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt @@ -12,6 +12,7 @@ each license. package org.contentauth.c2pa import java.io.Closeable +import org.contentauth.c2pa.manifest.ManifestValidator /** * C2PA Builder for creating and signing manifest stores. @@ -116,26 +117,36 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } /** - * Default assertion labels that should be placed in `created_assertions`. + * Default assertion labels that are attributed to the signer (created assertions). * - * These are assertions that are typically generated by the signing application - * and should be attributed to the signer per the C2PA 2.3 specification. - * Override by passing a custom [C2PASettings] to [fromJson(String, C2PASettings)]. + * The C2PA 2.3 spec distinguishes between "created" assertions (attributed to the + * signer) and "gathered" assertions (from other workflow components, not attributed + * to the signer). Assertions whose labels match this list are marked as created; + * all others are treated as gathered. + * + * Note: CAWG identity assertions (`cawg.identity`) cannot be added via the manifest + * definition. They are dynamic assertions generated at signing time when a CAWG X.509 + * signer is configured in the settings (`cawg_x509_signer` section). + * + * To customize, use [fromJson] with a [C2PASettings] that includes your own + * `builder.created_assertion_labels` setting. */ val DEFAULT_CREATED_ASSERTION_LABELS: List = listOf( "c2pa.actions", + "c2pa.actions.v2", "c2pa.thumbnail.claim", "c2pa.thumbnail.ingredient", "c2pa.ingredient", + "c2pa.ingredient.v3", ) /** * Creates a builder from a manifest definition in JSON format. * - * This method automatically configures the SDK to place common assertions - * (actions, thumbnails, metadata) in `created_assertions` as intended by - * most applications. CAWG identity assertions are correctly placed in - * `gathered_assertions` per the CAWG specification. + * This method automatically configures the SDK with [DEFAULT_CREATED_ASSERTION_LABELS] + * to mark common assertions (actions, thumbnails, ingredients) as created assertions. + * Assertions with labels not in the list are automatically treated as gathered + * assertions. * * For full control over settings, use [fromJson(String, C2PASettings)]. * @@ -166,8 +177,9 @@ class Builder internal constructor(private var ptr: Long) : Closeable { @JvmStatic @Throws(C2PAError::class) fun fromJson(manifestJSON: String): Builder { - if (manifestJSON.isBlank()) { - throw C2PAError.Api("Manifest JSON must not be empty") + val validation = ManifestValidator.validateJson(manifestJSON, logWarnings = true) + if (validation.hasErrors()) { + throw C2PAError.Api(validation.errors.joinToString("; ")) } val labelsArray = DEFAULT_CREATED_ASSERTION_LABELS.joinToString(", ") { "\"$it\"" } @@ -265,8 +277,9 @@ class Builder internal constructor(private var ptr: Long) : Closeable { @JvmStatic @Throws(C2PAError::class) fun fromJson(manifestJSON: String, settings: C2PASettings): Builder { - if (manifestJSON.isBlank()) { - throw C2PAError.Api("Manifest JSON must not be empty") + val validation = ManifestValidator.validateJson(manifestJSON, logWarnings = true) + if (validation.hasErrors()) { + throw C2PAError.Api(validation.errors.joinToString("; ")) } val context = C2PAContext.fromSettings(settings) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Intent.kt b/library/src/main/kotlin/org/contentauth/c2pa/Intent.kt index 92e668b..b9cc11f 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Intent.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Intent.kt @@ -159,4 +159,44 @@ enum class DigitalSourceType { COMPOSITE_CAPTURE -> 17 COMPOSITE_SYNTHETIC -> 18 } + + /** + * Converts this digital source type to its corresponding IPTC URL. + * + * @return The IPTC URL representing this digital source type. + */ + fun toIptcUrl(): String = + when (this) { + EMPTY -> "http://c2pa.org/digitalsourcetype/empty" + TRAINED_ALGORITHMIC_DATA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicData" + DIGITAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" + COMPUTATIONAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + NEGATIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/negativeFilm" + POSITIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/positiveFilm" + PRINT -> "http://cv.iptc.org/newscodes/digitalsourcetype/print" + HUMAN_EDITS -> "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + ALGORITHMICALLY_ENHANCED -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced" + DIGITAL_CREATION -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + DATA_DRIVEN_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/dataDrivenMedia" + TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia" + SCREEN_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/screenCapture" + VIRTUAL_RECORDING -> "http://cv.iptc.org/newscodes/digitalsourcetype/virtualRecording" + COMPOSITE -> "http://cv.iptc.org/newscodes/digitalsourcetype/composite" + COMPOSITE_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture" + COMPOSITE_SYNTHETIC -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic" + } + + companion object { + private val urlToType = entries.associateBy { it.toIptcUrl() } + + /** + * Parses an IPTC URL to its corresponding DigitalSourceType. + * + * @param url The IPTC URL to parse. + * @return The corresponding DigitalSourceType, or null if not recognized. + */ + fun fromIptcUrl(url: String): DigitalSourceType? = urlToType[url] + } } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/PredefinedAction.kt b/library/src/main/kotlin/org/contentauth/c2pa/PredefinedAction.kt index 522b99f..6d874aa 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/PredefinedAction.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/PredefinedAction.kt @@ -35,6 +35,9 @@ enum class PredefinedAction(val value: String) { /** Reduced or increased playback speed of a video or audio track. */ CHANGED_SPEED("c2pa.changedSpeed"), + /** [DEPRECATED] Use ADJUSTED_COLOR instead. */ + COLOR_ADJUSTMENTS("c2pa.color_adjustments"), + /** The format of the asset was changed. */ CONVERTED("c2pa.converted"), @@ -70,6 +73,12 @@ enum class PredefinedAction(val value: String) { /** Changes to appearance with applied filters, styles, etc. */ FILTERED("c2pa.filtered"), + /** Final production step where assets are prepared for distribution. */ + MASTERED("c2pa.mastered"), + + /** Multiple audio ingredients (stems, vocals, drums, etc.) are combined and transformed. */ + MIXED("c2pa.mixed"), + /** An existing asset was opened and is being set as the parentOf ingredient. */ OPENED("c2pa.opened"), @@ -85,6 +94,9 @@ enum class PredefinedAction(val value: String) { /** One or more assertions were redacted. */ REDACTED("c2pa.redacted"), + /** Components from one or more ingredients were combined in a transformative way. */ + REMIXED("c2pa.remixed"), + /** A componentOf ingredient was removed. */ REMOVED("c2pa.removed"), @@ -98,6 +110,9 @@ enum class PredefinedAction(val value: String) { /** Changes to either content dimensions, its file size or both. */ RESIZED("c2pa.resized"), + /** Dimensions were changed while maintaining aspect ratio. */ + RESIZED_PROPORTIONAL("c2pa.resized.proportional"), + /** * A conversion of one encoding to another, including resolution scaling, bitrate adjustment and * encoding format change. This action is considered as a non-editorial transformation of the @@ -119,4 +134,51 @@ enum class PredefinedAction(val value: String) { * soft binding. */ WATERMARKED("c2pa.watermarked"), + + /** + * An invisible watermark was inserted that is cryptographically bound to this manifest + * (soft binding). + */ + WATERMARKED_BOUND("c2pa.watermarked.bound"), + + /** + * An invisible watermark was inserted that is NOT cryptographically bound to this manifest + * (e.g., for tracking purposes). + */ + WATERMARKED_UNBOUND("c2pa.watermarked.unbound"), + + // Font actions (from Font Content Specification) + + /** Characters or character sets were added to the font. */ + FONT_CHARACTERS_ADDED("font.charactersAdded"), + + /** Characters or character sets were deleted from the font. */ + FONT_CHARACTERS_DELETED("font.charactersDeleted"), + + /** Characters were both added and deleted from the font. */ + FONT_CHARACTERS_MODIFIED("font.charactersModified"), + + /** A font was instantiated from a variable font. */ + FONT_CREATED_FROM_VARIABLE_FONT("font.createdFromVariableFont"), + + /** The font was edited (catch-all). */ + FONT_EDITED("font.edited"), + + /** Hinting was applied to the font. */ + FONT_HINTED("font.hinted"), + + /** A combination of antecedent fonts. */ + FONT_MERGED("font.merged"), + + /** An OpenType feature was added. */ + FONT_OPEN_TYPE_FEATURE_ADDED("font.openTypeFeatureAdded"), + + /** An OpenType feature was modified. */ + FONT_OPEN_TYPE_FEATURE_MODIFIED("font.openTypeFeatureModified"), + + /** An OpenType feature was removed. */ + FONT_OPEN_TYPE_FEATURE_REMOVED("font.openTypeFeatureRemoved"), + + /** The font was stripped to a sub-group of characters. */ + FONT_SUBSET("font.subset"), } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ActionAssertion.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ActionAssertion.kt index 155a290..3ad0b4e 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ActionAssertion.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ActionAssertion.kt @@ -14,6 +14,11 @@ package org.contentauth.c2pa.manifest import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.contentauth.c2pa.C2PAJson import org.contentauth.c2pa.DigitalSourceType import org.contentauth.c2pa.PredefinedAction @@ -22,11 +27,15 @@ import org.contentauth.c2pa.PredefinedAction * * This is the serializable version of Action for use within AssertionDefinition. * + * In C2PA v2, `softwareAgent` may be either a plain string (v1 format) or a + * `generator-info-map` object (v2 format). Use [softwareAgentString] or + * [softwareAgentInfo] to access the value in the desired format. + * * @property action The action name (use [PredefinedAction.value] or a custom action string). * @property digitalSourceType A URL identifying an IPTC digital source type. - * @property softwareAgent The software or hardware used to perform the action. + * @property softwareAgent The software or hardware used to perform the action (string or object). * @property parameters Additional information describing the action. - * @property when The timestamp when the action was performed (ISO 8601 format). + * @property whenPerformed The timestamp when the action was performed (ISO 8601 format). * @property changes Regions of interest describing what changed. * @property related Related ingredient labels. * @property reason The reason for performing the action. @@ -37,14 +46,29 @@ import org.contentauth.c2pa.PredefinedAction data class ActionAssertion( val action: String, val digitalSourceType: String? = null, - val softwareAgent: String? = null, - val parameters: Map? = null, + val softwareAgent: JsonElement? = null, + val parameters: Map? = null, @SerialName("when") val whenPerformed: String? = null, val changes: List? = null, val related: List? = null, val reason: String? = null, ) { + + /** Returns the softwareAgent as a string if it is a JSON string, null otherwise. */ + val softwareAgentString: String? + get() = (softwareAgent as? JsonPrimitive)?.contentOrNull + + /** Returns the softwareAgent as a [ClaimGeneratorInfo] if it is a JSON object, null otherwise. */ + val softwareAgentInfo: ClaimGeneratorInfo? + get() = softwareAgent?.let { + try { + C2PAJson.default.decodeFromJsonElement(ClaimGeneratorInfo.serializer(), it) + } catch (_: Exception) { + null + } + } + /** * Creates an action using a [PredefinedAction] and [DigitalSourceType]. */ @@ -52,7 +76,7 @@ data class ActionAssertion( action: PredefinedAction, digitalSourceType: DigitalSourceType? = null, softwareAgent: String? = null, - parameters: Map? = null, + parameters: Map? = null, whenPerformed: String? = null, changes: List? = null, related: List? = null, @@ -60,7 +84,30 @@ data class ActionAssertion( ) : this( action = action.value, digitalSourceType = digitalSourceType?.toIptcUrl(), - softwareAgent = softwareAgent, + softwareAgent = softwareAgent?.let { JsonPrimitive(it) }, + parameters = parameters, + whenPerformed = whenPerformed, + changes = changes, + related = related, + reason = reason, + ) + + /** + * Creates an action using a [PredefinedAction] with a [ClaimGeneratorInfo] as v2 softwareAgent. + */ + constructor( + action: PredefinedAction, + digitalSourceType: DigitalSourceType? = null, + softwareAgentInfo: ClaimGeneratorInfo, + parameters: Map? = null, + whenPerformed: String? = null, + changes: List? = null, + related: List? = null, + reason: String? = null, + ) : this( + action = action.value, + digitalSourceType = digitalSourceType?.toIptcUrl(), + softwareAgent = C2PAJson.default.encodeToJsonElement(ClaimGeneratorInfo.serializer(), softwareAgentInfo), parameters = parameters, whenPerformed = whenPerformed, changes = changes, @@ -94,26 +141,3 @@ data class ActionAssertion( ) } } - -private fun DigitalSourceType.toIptcUrl(): String = - when (this) { - DigitalSourceType.EMPTY -> "http://c2pa.org/digitalsourcetype/empty" - DigitalSourceType.TRAINED_ALGORITHMIC_DATA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicData" - DigitalSourceType.DIGITAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" - DigitalSourceType.COMPUTATIONAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" - DigitalSourceType.NEGATIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/negativeFilm" - DigitalSourceType.POSITIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/positiveFilm" - DigitalSourceType.PRINT -> "http://cv.iptc.org/newscodes/digitalsourcetype/print" - DigitalSourceType.HUMAN_EDITS -> "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" - DigitalSourceType.COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - DigitalSourceType.ALGORITHMICALLY_ENHANCED -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced" - DigitalSourceType.DIGITAL_CREATION -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - DigitalSourceType.DATA_DRIVEN_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/dataDrivenMedia" - DigitalSourceType.TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" - DigitalSourceType.ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia" - DigitalSourceType.SCREEN_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/screenCapture" - DigitalSourceType.VIRTUAL_RECORDING -> "http://cv.iptc.org/newscodes/digitalsourcetype/virtualRecording" - DigitalSourceType.COMPOSITE -> "http://cv.iptc.org/newscodes/digitalsourcetype/composite" - DigitalSourceType.COMPOSITE_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture" - DigitalSourceType.COMPOSITE_SYNTHETIC -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic" - } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/AssertionDefinition.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/AssertionDefinition.kt index 0ca89bf..335a8f9 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/AssertionDefinition.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/AssertionDefinition.kt @@ -50,6 +50,9 @@ sealed class AssertionDefinition { data class Actions( val actions: List, val metadata: Metadata? = null, + val templates: List? = null, + val softwareAgents: List? = null, + val allActionsIncluded: Boolean? = null, ) : AssertionDefinition() /** @@ -88,6 +91,18 @@ sealed class AssertionDefinition { val entries: List, ) : AssertionDefinition() + /** + * A CAWG AI training and data mining assertion. + * + * This follows the CAWG AI Training and Data Mining specification format, + * which is distinct from the C2PA training-mining assertion. + * + * @property entries The CAWG training/mining permission entries. + */ + data class CawgTrainingMining( + val entries: List, + ) : AssertionDefinition() + /** * A custom assertion with an arbitrary label and data. * @@ -99,6 +114,23 @@ sealed class AssertionDefinition { val data: JsonElement, ) : AssertionDefinition() + /** + * Returns the base label for this assertion type. + * + * The base label is used by the SDK to determine whether this assertion should be + * placed in `created_assertions` or `gathered_assertions` based on the + * `builder.created_assertion_labels` setting. + */ + fun baseLabel(): String = when (this) { + is Actions -> "c2pa.actions" + is CreativeWork -> "stds.schema-org.CreativeWork" + is Exif -> "stds.exif" + is IptcPhotoMetadata -> "stds.iptc.photo-metadata" + is TrainingMining -> "c2pa.training-mining" + is CawgTrainingMining -> "cawg.training-mining" + is Custom -> label + } + companion object { /** * Creates an actions assertion with the specified actions. @@ -124,10 +156,15 @@ sealed class AssertionDefinition { fun exif(data: Map) = Exif(data) /** - * Creates a training/mining assertion. + * Creates a training/mining assertion (C2PA format). */ fun trainingMining(entries: List) = TrainingMining(entries) + /** + * Creates a CAWG AI training and data mining assertion. + */ + fun cawgTrainingMining(entries: List) = CawgTrainingMining(entries) + /** * Creates a custom assertion. */ @@ -136,7 +173,7 @@ sealed class AssertionDefinition { } /** - * Represents a training/mining permission entry. + * Represents a training/mining permission entry (C2PA format). * * @property use The type of use (e.g., "allowed", "notAllowed", "constrained"). * @property constraint Optional constraint URL or description. @@ -148,6 +185,28 @@ data class TrainingMiningEntry( val constraintInfo: String? = null, ) +/** + * Represents a CAWG AI training and data mining permission entry. + * + * This follows the CAWG specification format which has additional fields + * compared to the C2PA training-mining assertion. + * + * @property use The use permission: "allowed", "notAllowed", or "constrained". + * @property constraintInfo Optional constraint information URI. + * @property aiModelLearningType Optional learning type: "dataAggregation" or "machineLearning". + * @property aiMiningType Optional mining type: "dataAggregation" or "other". + */ +@Serializable +data class CawgTrainingMiningEntry( + val use: String, + @SerialName("constraint_info") + val constraintInfo: String? = null, + @SerialName("ai_model_learning_type") + val aiModelLearningType: String? = null, + @SerialName("ai_mining_type") + val aiMiningType: String? = null, +) + /** * Custom serializer for AssertionDefinition that handles the label/data structure. */ @@ -161,10 +220,13 @@ internal object AssertionDefinitionSerializer : KSerializer val jsonObject = when (value) { is AssertionDefinition.Actions -> buildJsonObject { - put("label", StandardAssertionLabel.ACTIONS.serialName()) + put("label", StandardAssertionLabel.ACTIONS_V2.serialName()) put("data", buildJsonObject { put("actions", jsonEncoder.json.encodeToJsonElement(value.actions)) value.metadata?.let { put("metadata", jsonEncoder.json.encodeToJsonElement(it)) } + value.templates?.let { put("templates", jsonEncoder.json.encodeToJsonElement(it)) } + value.softwareAgents?.let { put("softwareAgents", jsonEncoder.json.encodeToJsonElement(it)) } + value.allActionsIncluded?.let { put("allActionsIncluded", it) } }) } is AssertionDefinition.CreativeWork -> buildJsonObject { @@ -185,6 +247,12 @@ internal object AssertionDefinitionSerializer : KSerializer put("entries", jsonEncoder.json.encodeToJsonElement(value.entries)) }) } + is AssertionDefinition.CawgTrainingMining -> buildJsonObject { + put("label", StandardAssertionLabel.CAWG_AI_TRAINING.serialName()) + put("data", buildJsonObject { + put("entries", jsonEncoder.json.encodeToJsonElement(value.entries)) + }) + } is AssertionDefinition.Custom -> buildJsonObject { put("label", value.label) put("data", value.data) @@ -215,7 +283,22 @@ internal object AssertionDefinitionSerializer : KSerializer val metadata = data["metadata"]?.let { jsonDecoder.json.decodeFromJsonElement(Metadata.serializer(), it) } - AssertionDefinition.Actions(actions, metadata) + val templates = data["templates"]?.let { + jsonDecoder.json.decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(ActionAssertion.serializer()), + it, + ) + } + val softwareAgents = data["softwareAgents"]?.let { + jsonDecoder.json.decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(ClaimGeneratorInfo.serializer()), + it, + ) + } + val allActionsIncluded = data["allActionsIncluded"]?.jsonPrimitive?.let { + it.content.toBooleanStrictOrNull() + } + AssertionDefinition.Actions(actions, metadata, templates, softwareAgents, allActionsIncluded) } StandardAssertionLabel.CREATIVE_WORK.serialName() -> { AssertionDefinition.CreativeWork(data.toMap()) @@ -235,30 +318,18 @@ internal object AssertionDefinitionSerializer : KSerializer } ?: emptyList() AssertionDefinition.TrainingMining(entries) } + StandardAssertionLabel.CAWG_AI_TRAINING.serialName() -> { + val entries = data["entries"]?.let { + jsonDecoder.json.decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(CawgTrainingMiningEntry.serializer()), + it, + ) + } ?: emptyList() + AssertionDefinition.CawgTrainingMining(entries) + } else -> { AssertionDefinition.Custom(label, JsonObject(data)) } } } } - -private fun StandardAssertionLabel.serialName(): String = when (this) { - StandardAssertionLabel.ACTIONS -> "c2pa.actions" - StandardAssertionLabel.ACTIONS_V2 -> "c2pa.actions.v2" - StandardAssertionLabel.HASH_DATA -> "c2pa.hash.data" - StandardAssertionLabel.HASH_BOXES -> "c2pa.hash.boxes" - StandardAssertionLabel.HASH_BMFF_V2 -> "c2pa.hash.bmff.v2" - StandardAssertionLabel.HASH_COLLECTION -> "c2pa.hash.collection" - StandardAssertionLabel.SOFT_BINDING -> "c2pa.soft-binding" - StandardAssertionLabel.CLOUD_DATA -> "c2pa.cloud-data" - StandardAssertionLabel.THUMBNAIL_CLAIM -> "c2pa.thumbnail.claim" - StandardAssertionLabel.THUMBNAIL_INGREDIENT -> "c2pa.thumbnail.ingredient" - StandardAssertionLabel.DEPTHMAP -> "c2pa.depthmap" - StandardAssertionLabel.TRAINING_MINING -> "c2pa.training-mining" - StandardAssertionLabel.EXIF -> "stds.exif" - StandardAssertionLabel.CREATIVE_WORK -> "stds.schema-org.CreativeWork" - StandardAssertionLabel.IPTC_PHOTO_METADATA -> "stds.iptc.photo-metadata" - StandardAssertionLabel.ISO_LOCATION -> "stds.iso.location.v1" - StandardAssertionLabel.CAWG_IDENTITY -> "cawg.identity" - StandardAssertionLabel.CAWG_AI_TRAINING -> "cawg.ai_training_and_data_mining" -} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ImageRegionType.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ImageRegionType.kt index b57765c..5b0fa0e 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ImageRegionType.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ImageRegionType.kt @@ -87,4 +87,25 @@ enum class ImageRegionType { /** A geographical feature or landmark. */ @SerialName("http://cv.iptc.org/newscodes/imageregiontype/geoFeature") GEO_FEATURE, + ; + + /** Returns the IPTC image region type URL string for this type. */ + fun toTypeString(): String = when (this) { + HUMAN -> "http://cv.iptc.org/newscodes/imageregiontype/human" + FACE -> "http://cv.iptc.org/newscodes/imageregiontype/face" + HEADSHOT -> "http://cv.iptc.org/newscodes/imageregiontype/headshot" + BODY_PART -> "http://cv.iptc.org/newscodes/imageregiontype/bodyPart" + ANIMAL -> "http://cv.iptc.org/newscodes/imageregiontype/animal" + PLANT -> "http://cv.iptc.org/newscodes/imageregiontype/plant" + PRODUCT -> "http://cv.iptc.org/newscodes/imageregiontype/product" + BUILDING -> "http://cv.iptc.org/newscodes/imageregiontype/building" + OBJECT -> "http://cv.iptc.org/newscodes/imageregiontype/object" + VEHICLE -> "http://cv.iptc.org/newscodes/imageregiontype/vehicle" + EVENT -> "http://cv.iptc.org/newscodes/imageregiontype/event" + ARTWORK -> "http://cv.iptc.org/newscodes/imageregiontype/artwork" + LOGO -> "http://cv.iptc.org/newscodes/imageregiontype/logo" + TEXT -> "http://cv.iptc.org/newscodes/imageregiontype/text" + VISIBLE_CODE -> "http://cv.iptc.org/newscodes/imageregiontype/visibleCode" + GEO_FEATURE -> "http://cv.iptc.org/newscodes/imageregiontype/geoFeature" + } } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/Ingredient.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/Ingredient.kt index f0ec883..6b19f55 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/Ingredient.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/Ingredient.kt @@ -49,27 +49,33 @@ data class Ingredient( val relationship: Relationship? = null, val data: ResourceRef? = null, val thumbnail: ResourceRef? = null, - @SerialName("manifest_data") + @SerialName("manifestData") val manifestData: ResourceRef? = null, - @SerialName("active_manifest") + @SerialName("activeManifest") val activeManifest: String? = null, val hash: String? = null, val description: String? = null, val label: String? = null, - @SerialName("data_types") + @SerialName("dataTypes") val dataTypes: List? = null, - @SerialName("validation_status") + @SerialName("validationStatus") val validationStatus: List? = null, - @SerialName("validation_results") + @SerialName("validationResults") val validationResults: ValidationResults? = null, val metadata: Metadata? = null, - @SerialName("document_id") + @SerialName("documentId") val documentId: String? = null, - @SerialName("instance_id") + @SerialName("instanceId") val instanceId: String? = null, val provenance: String? = null, - @SerialName("informational_uri") + @SerialName("informationalUri") val informationalUri: String? = null, + @SerialName("claimSignature") + val claimSignature: HashedUri? = null, + @SerialName("softBindingsMatched") + val softBindingsMatched: Boolean? = null, + @SerialName("softBindingAlgorithmsMatched") + val softBindingAlgorithmsMatched: List? = null, ) { companion object { /** @@ -101,5 +107,23 @@ data class Ingredient( format = format, relationship = Relationship.COMPONENT_OF, ) + + /** + * Creates an inputTo ingredient with the specified title. + * + * Use this relationship when an asset is derived from or influenced by another asset, + * but the ingredient is not directly embedded or used as a component. + * + * @param title The title of the input ingredient. + * @param format The MIME type of the ingredient. + */ + fun inputTo( + title: String, + format: String? = null, + ) = Ingredient( + title = title, + format = format, + relationship = Relationship.INPUT_TO, + ) } } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestDefinition.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestDefinition.kt index 6b0f840..eb73876 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestDefinition.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestDefinition.kt @@ -15,7 +15,7 @@ package org.contentauth.c2pa.manifest import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import org.contentauth.c2pa.C2PAJson /** * Defines a C2PA manifest for content authenticity. @@ -23,6 +23,28 @@ import kotlinx.serialization.json.Json * ManifestDefinition is the root type for building C2PA manifests. It contains all the * information needed to create a signed manifest, including claims, assertions, and ingredients. * + * ## Created vs Gathered Assertions + * + * The C2PA 2.3 specification distinguishes between created assertions (attributed to the signer) + * and gathered assertions (from other workflow components, not attributed to the signer). + * + * All assertions are placed in the [assertions] list. The underlying c2pa-rs SDK uses the + * `created_assertion_labels` setting to determine which assertions are "created" (attributed + * to the signer) vs "gathered". By default, [org.contentauth.c2pa.Builder.fromJson] configures + * common labels (actions, thumbnails, ingredients) as created assertions. Assertions with labels + * NOT in that list are automatically treated as gathered. + * + * To customize which labels are created vs gathered, use + * [org.contentauth.c2pa.Builder.fromJson] with a custom [org.contentauth.c2pa.C2PASettings] + * that includes your desired `builder.created_assertion_labels`. + * + * ## CAWG Identity Assertions + * + * CAWG identity assertions (`cawg.identity`) cannot be added directly through the manifest + * definition. They are dynamic assertions generated by the c2pa-rs SDK at signing time when + * a CAWG X.509 signer is configured in the settings. Configure CAWG identity via the + * `cawg_x509_signer` section of [org.contentauth.c2pa.C2PASettings]. + * * ## Usage * * ```kotlin @@ -43,7 +65,8 @@ import kotlinx.serialization.json.Json * * @property title The title of the asset. * @property claimGeneratorInfo Information about the software creating the claim. - * @property assertions The list of assertions in this manifest. + * @property assertions The list of assertions in this manifest. Whether each assertion is + * treated as "created" or "gathered" depends on the `created_assertion_labels` SDK setting. * @property ingredients The list of ingredients (parent assets) used in this manifest. * @property thumbnail Reference to a thumbnail image for this asset. * @property format The MIME type of the asset (e.g., "image/jpeg"). @@ -51,6 +74,13 @@ import kotlinx.serialization.json.Json * @property label An optional unique label for this manifest. * @property instanceId An optional instance identifier. * @property redactions A list of assertion URIs to redact from ingredients. + * @property claimGenerator A plain-text claim generator string. Not currently supported by + * c2pa-rs; use [claimGeneratorInfo] instead. + * @property specVersion The specification version. Not currently supported by c2pa-rs; the + * SDK determines the spec version automatically. + * @property alg The signing algorithm. Not currently supported by c2pa-rs as a manifest + * definition field; use the signer configuration instead. + * @property algSoft The soft algorithm. Not currently supported by c2pa-rs. * @see AssertionDefinition * @see Ingredient * @see ClaimGeneratorInfo @@ -60,6 +90,12 @@ data class ManifestDefinition( val title: String, @SerialName("claim_generator_info") val claimGeneratorInfo: List, + /** + * The claim version. Defaults to 2 for C2PA 2.x specification compliance. + * Version 2 claims properly separate created_assertions from gathered_assertions. + */ + @SerialName("claim_version") + val claimVersion: Int = 2, val assertions: List = emptyList(), val ingredients: List = emptyList(), val thumbnail: ResourceRef? = null, @@ -69,7 +105,26 @@ data class ManifestDefinition( @SerialName("instance_id") val instanceId: String? = null, val redactions: List? = null, + @SerialName("claim_generator") + val claimGenerator: String? = null, + @SerialName("spec_version") + val specVersion: String? = null, + val alg: String? = null, + @SerialName("alg_soft") + val algSoft: String? = null, ) { + /** + * Returns the unique base labels of all assertions in this manifest. + * + * This can be used to build a custom `created_assertion_labels` setting for the + * [org.contentauth.c2pa.Builder] if you need fine-grained control over which + * assertions are attributed to the signer. + * + * @return A list of unique assertion base labels. + */ + fun assertionLabels(): List = + assertions.map { it.baseLabel() }.distinct() + /** * Converts this manifest definition to a JSON string. * @@ -77,29 +132,18 @@ data class ManifestDefinition( * * @return The manifest as a JSON string. */ - fun toJson(): String = json.encodeToString(this) + fun toJson(): String = C2PAJson.default.encodeToString(this) /** * Converts this manifest definition to a pretty-printed JSON string. * * @return The manifest as a formatted JSON string. */ - fun toPrettyJson(): String = prettyJson.encodeToString(this) + fun toPrettyJson(): String = C2PAJson.pretty.encodeToString(this) override fun toString(): String = toJson() companion object { - private val json = Json { - encodeDefaults = false - ignoreUnknownKeys = true - } - - private val prettyJson = Json { - encodeDefaults = false - ignoreUnknownKeys = true - prettyPrint = true - } - /** * Parses a ManifestDefinition from a JSON string. * @@ -107,7 +151,7 @@ data class ManifestDefinition( * @return The parsed ManifestDefinition. */ fun fromJson(jsonString: String): ManifestDefinition = - json.decodeFromString(jsonString) + C2PAJson.default.decodeFromString(jsonString) /** * Creates a minimal manifest definition for a newly created asset. @@ -129,5 +173,28 @@ data class ManifestDefinition( ), ), ) + + /** + * Creates a manifest definition for an edited asset with a parent ingredient. + * + * @param title The title of the asset. + * @param claimGeneratorInfo The claim generator info. + * @param parentIngredient The parent ingredient that was edited. + * @param editActions The list of edit actions performed. + */ + fun edited( + title: String, + claimGeneratorInfo: ClaimGeneratorInfo, + parentIngredient: Ingredient, + editActions: List, + ) = ManifestDefinition( + title = title, + claimGeneratorInfo = listOf(claimGeneratorInfo), + assertions = listOf( + AssertionDefinition.actions(editActions), + ), + ingredients = listOf(parentIngredient), + ) + } } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestValidator.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestValidator.kt new file mode 100644 index 0000000..44edf5b --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestValidator.kt @@ -0,0 +1,266 @@ +/* +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. +*/ + +package org.contentauth.c2pa.manifest + +import android.util.Log +import kotlinx.serialization.json.JsonObject +import org.contentauth.c2pa.C2PAJson +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Validates C2PA manifests for spec compliance and provides warnings for common issues. + * + * This validator checks for compliance with C2PA 2.3 specification requirements + * and CAWG specification requirements. + * + * ## Usage + * + * ```kotlin + * val manifest = ManifestDefinition(...) + * val result = ManifestValidator.validate(manifest) + * if (result.hasErrors()) { + * result.errors.forEach { println("Error: $it") } + * } + * if (result.hasWarnings()) { + * result.warnings.forEach { println("Warning: $it") } + * } + * ``` + */ +object ManifestValidator { + + private const val TAG = "C2PA" + + /** + * Deprecated assertion labels per C2PA 2.x specification. + * These are still supported but should not be used in new manifests. + */ + val DEPRECATED_ASSERTION_LABELS: Set = setOf( + "stds.exif", + "stds.iptc.photo-metadata", + "stds.schema-org.CreativeWork", + "c2pa.endorsement", + "c2pa.data", + "c2pa.databoxes", + "c2pa.font.info", + ) + + /** + * The current recommended claim version for C2PA 2.x specification. + */ + const val RECOMMENDED_CLAIM_VERSION = 2 + + /** + * Validates a manifest definition for C2PA 2.3 spec compliance. + * + * @param manifest The manifest to validate. + * @return A ValidationResult with any errors or warnings found. + */ + fun validate(manifest: ManifestDefinition): ValidationResult { + val errors = mutableListOf() + val warnings = mutableListOf() + + // Check claim version + if (manifest.claimVersion != RECOMMENDED_CLAIM_VERSION) { + warnings.add( + "claim_version is ${manifest.claimVersion}, but C2PA 2.x recommends version $RECOMMENDED_CLAIM_VERSION. " + + "Version 1 claims use legacy assertion formats and do not support created/gathered assertion separation.", + ) + } + + // Check for required fields + if (manifest.title.isBlank()) { + errors.add("Manifest title is required") + } + + if (manifest.claimGeneratorInfo.isEmpty()) { + errors.add("At least one claim_generator_info entry is required") + } + + // Check for deprecated assertions + checkDeprecatedAssertions(manifest.assertions, warnings) + + // Validate assertion labels + manifest.assertions.forEach { assertion -> + validateAssertionLabel(assertion, warnings) + } + + // Validate ingredients + manifest.ingredients.forEach { ingredient -> + if (ingredient.relationship == null) { + warnings.add( + "Ingredient '${ingredient.title ?: "unnamed"}' has no relationship specified. " + + "Consider using parentOf, componentOf, or inputTo.", + ) + } + } + + return ValidationResult(errors, warnings) + } + + private fun validateAssertionLabel(assertion: AssertionDefinition, warnings: MutableList) { + when (assertion) { + is AssertionDefinition.Custom -> { + val label = assertion.label + // Check for standard label patterns + if (!label.contains(".") && !label.contains(":")) { + warnings.add( + "Custom assertion label '$label' should use namespaced format " + + "(e.g., 'com.example.custom' or vendor prefix).", + ) + } + // Check for common typos in standard labels + val commonTypos = mapOf( + "c2pa.action" to "c2pa.actions", + "stds.iptc" to "stds.iptc.photo-metadata", + "cawg.training" to "cawg.training-mining", + ) + commonTypos[label]?.let { correct -> + warnings.add("Label '$label' may be a typo. Did you mean '$correct'?") + } + } + else -> { + // Standard types have validated labels + } + } + } + + /** + * Checks for deprecated assertion types and adds warnings. + */ + private fun checkDeprecatedAssertions( + assertions: List, + warnings: MutableList, + ) { + assertions.forEach { assertion -> + val label = assertion.baseLabel() + if (label in DEPRECATED_ASSERTION_LABELS) { + val replacement = getDeprecatedAssertionReplacement(label) + warnings.add( + "Assertion '$label' is deprecated in C2PA 2.x. $replacement", + ) + } + } + } + + /** + * Returns replacement guidance for deprecated assertion labels. + */ + private fun getDeprecatedAssertionReplacement(label: String): String = when (label) { + "stds.exif" -> "Consider using c2pa.metadata or embedding EXIF in the asset directly." + "stds.iptc.photo-metadata" -> "Consider using c2pa.metadata instead." + "stds.schema-org.CreativeWork" -> "Consider using c2pa.metadata instead." + "c2pa.endorsement" -> "Endorsement assertions are no longer supported in C2PA 2.x." + "c2pa.data" -> "Use c2pa.embedded-data instead." + "c2pa.databoxes" -> "Data box stores are deprecated in C2PA 2.x." + "c2pa.font.info" -> "Use font.info instead." + else -> "Check the C2PA 2.3 specification for current alternatives." + } + + /** + * Validates a raw JSON manifest string and logs warnings to the console. + * + * This method parses the JSON and checks for: + * - Non-v2 claim versions + * - Deprecated assertion labels + * - CAWG assertions in wrong location + * - Other spec compliance issues + * + * @param manifestJson The manifest JSON string to validate. + * @param logWarnings If true (default), warnings are logged to the Android console. + * @return A ValidationResult with any errors or warnings found. + */ + fun validateJson(manifestJson: String, logWarnings: Boolean = true): ValidationResult { + val errors = mutableListOf() + val warnings = mutableListOf() + + try { + val jsonObject = C2PAJson.default.parseToJsonElement(manifestJson).jsonObject + + // Check claim_version + val claimVersion = jsonObject["claim_version"]?.jsonPrimitive?.intOrNull + if (claimVersion != null && claimVersion != RECOMMENDED_CLAIM_VERSION) { + warnings.add( + "claim_version is $claimVersion, but C2PA 2.x recommends version $RECOMMENDED_CLAIM_VERSION. " + + "Version 1 claims use legacy assertion formats (c2pa.actions instead of c2pa.actions.v2) " + + "and do not support created/gathered assertion separation.", + ) + } + + // Check assertions for deprecated labels + jsonObject["assertions"]?.jsonArray?.forEach { assertionElement -> + val assertionObj = assertionElement.jsonObject + val label = assertionObj["label"]?.jsonPrimitive?.content + if (label != null) { + checkJsonAssertionLabel(label, warnings, "assertions") + } + } + + } catch (e: Exception) { + errors.add("Failed to parse manifest JSON: ${e.message}") + } + + // Log warnings if requested + if (logWarnings) { + logValidationResults(errors, warnings) + } + + return ValidationResult(errors, warnings) + } + + /** + * Checks a JSON assertion label for deprecation and issues. + */ + private fun checkJsonAssertionLabel( + label: String, + warnings: MutableList, + location: String, + ) { + // Check for deprecated labels + if (label in DEPRECATED_ASSERTION_LABELS) { + val replacement = getDeprecatedAssertionReplacement(label) + warnings.add( + "Assertion '$label' in $location is deprecated in C2PA 2.x. $replacement", + ) + } + + } + + /** + * Logs validation results to the Android console. + */ + private fun logValidationResults(errors: List, warnings: List) { + errors.forEach { error -> + Log.e(TAG, "Manifest validation error: $error") + } + warnings.forEach { warning -> + Log.w(TAG, "Manifest validation warning: $warning") + } + } + + /** + * Validates and logs warnings for a ManifestDefinition. + * + * Convenience method that validates and logs in one call. + * + * @param manifest The manifest to validate. + * @return A ValidationResult with any errors or warnings found. + */ + fun validateAndLog(manifest: ManifestDefinition): ValidationResult { + val result = validate(manifest) + logValidationResults(result.errors, result.warnings) + return result + } +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/RegionOfInterest.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/RegionOfInterest.kt index a730599..3cf94de 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/RegionOfInterest.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/RegionOfInterest.kt @@ -100,22 +100,3 @@ data class RegionOfInterest( ) } } - -private fun ImageRegionType.toTypeString(): String = when (this) { - ImageRegionType.HUMAN -> "http://cv.iptc.org/newscodes/imageregiontype/human" - ImageRegionType.FACE -> "http://cv.iptc.org/newscodes/imageregiontype/face" - ImageRegionType.HEADSHOT -> "http://cv.iptc.org/newscodes/imageregiontype/headshot" - ImageRegionType.BODY_PART -> "http://cv.iptc.org/newscodes/imageregiontype/bodyPart" - ImageRegionType.ANIMAL -> "http://cv.iptc.org/newscodes/imageregiontype/animal" - ImageRegionType.PLANT -> "http://cv.iptc.org/newscodes/imageregiontype/plant" - ImageRegionType.PRODUCT -> "http://cv.iptc.org/newscodes/imageregiontype/product" - ImageRegionType.BUILDING -> "http://cv.iptc.org/newscodes/imageregiontype/building" - ImageRegionType.OBJECT -> "http://cv.iptc.org/newscodes/imageregiontype/object" - ImageRegionType.VEHICLE -> "http://cv.iptc.org/newscodes/imageregiontype/vehicle" - ImageRegionType.EVENT -> "http://cv.iptc.org/newscodes/imageregiontype/event" - ImageRegionType.ARTWORK -> "http://cv.iptc.org/newscodes/imageregiontype/artwork" - ImageRegionType.LOGO -> "http://cv.iptc.org/newscodes/imageregiontype/logo" - ImageRegionType.TEXT -> "http://cv.iptc.org/newscodes/imageregiontype/text" - ImageRegionType.VISIBLE_CODE -> "http://cv.iptc.org/newscodes/imageregiontype/visibleCode" - ImageRegionType.GEO_FEATURE -> "http://cv.iptc.org/newscodes/imageregiontype/geoFeature" -} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/Role.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/Role.kt index 3a838c8..948e055 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/Role.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/Role.kt @@ -18,6 +18,10 @@ import kotlinx.serialization.Serializable /** * Defines the role of a region within an asset. * + * Note: The `role` field on regions is deprecated since C2PA v2.1. Use the `type` field + * with IPTC image region type URIs instead. These values are retained for backward + * compatibility when reading older manifests. + * * @see RegionOfInterest */ @Serializable @@ -26,6 +30,14 @@ enum class Role { @SerialName("c2pa.areaOfInterest") AREA_OF_INTEREST, + /** A region that has been cropped. */ + @SerialName("c2pa.cropped") + CROPPED, + + /** A region that has been deleted or removed. */ + @SerialName("c2pa.deleted") + DELETED, + /** A region that has been edited or modified. */ @SerialName("c2pa.edited") EDITED, @@ -34,15 +46,19 @@ enum class Role { @SerialName("c2pa.placed") PLACED, - /** A region that has been cropped. */ - @SerialName("c2pa.cropped") - CROPPED, + /** A region that has been redacted. */ + @SerialName("c2pa.redacted") + REDACTED, - /** A region that has been deleted or removed. */ - @SerialName("c2pa.deleted") - DELETED, + /** A region that has been styled. */ + @SerialName("c2pa.styled") + STYLED, + + /** The subject area of the asset. */ + @SerialName("c2pa.subjectArea") + SUBJECT_AREA, - /** A region where invisible watermark was added. */ - @SerialName("c2pa.invisible") - INVISIBLE, + /** A region where a watermark was applied. */ + @SerialName("c2pa.watermarked") + WATERMARKED, } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/StandardAssertionLabel.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/StandardAssertionLabel.kt index 29d2c7c..6fa7046 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/StandardAssertionLabel.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/StandardAssertionLabel.kt @@ -18,81 +18,172 @@ import kotlinx.serialization.Serializable /** * Standard C2PA assertion labels as defined in the C2PA specification. * - * These labels identify the type of assertion in a manifest. + * Labels marked "c2pa-rs" are recognized natively by the underlying c2pa-rs SDK. + * Labels marked "spec-only" are defined in the C2PA or CAWG specifications but not + * currently implemented as named constants in c2pa-rs; they can still be used as + * custom assertions and will be passed through by the SDK. * * @see AssertionDefinition */ @Serializable enum class StandardAssertionLabel { - /** Actions performed on the asset. */ + /** Actions performed on the asset (deprecated, use ACTIONS_V2). [c2pa-rs] */ @SerialName("c2pa.actions") ACTIONS, - /** Actions performed on the asset (version 2). */ + /** Actions performed on the asset (version 2). [c2pa-rs] */ @SerialName("c2pa.actions.v2") ACTIONS_V2, - /** Hash data for the asset. */ + /** Assertion metadata. [c2pa-rs] */ + @SerialName("c2pa.assertion.metadata") + ASSERTION_METADATA, + + /** Asset reference assertion. [c2pa-rs] */ + @SerialName("c2pa.asset-ref") + ASSET_REF, + + /** Asset type assertion. [c2pa-rs] */ + @SerialName("c2pa.asset-type") + ASSET_TYPE, + + /** Asset type assertion (version 2). [spec-only, not in c2pa-rs] */ + @SerialName("c2pa.asset-type.v2") + ASSET_TYPE_V2, + + /** Certificate status assertion. [c2pa-rs] */ + @SerialName("c2pa.certificate-status") + CERTIFICATE_STATUS, + + /** Cloud data assertion. [c2pa-rs] */ + @SerialName("c2pa.cloud-data") + CLOUD_DATA, + + /** Base depthmap assertion. [c2pa-rs] */ + @SerialName("c2pa.depthmap") + DEPTHMAP, + + /** GDepth depthmap assertion. [c2pa-rs] */ + @SerialName("c2pa.depthmap.GDepth") + DEPTHMAP_GDEPTH, + + /** Embedded data assertion. [c2pa-rs] */ + @SerialName("c2pa.embedded-data") + EMBEDDED_DATA, + + /** Hash data for the asset. [c2pa-rs] */ @SerialName("c2pa.hash.data") HASH_DATA, - /** Box hash data. */ + /** Box hash data. [c2pa-rs] */ @SerialName("c2pa.hash.boxes") HASH_BOXES, - /** BMFF v2 hash data. */ - @SerialName("c2pa.hash.bmff.v2") - HASH_BMFF_V2, + /** BMFF hash data (base label, auto-versioned by SDK). [c2pa-rs] */ + @SerialName("c2pa.hash.bmff") + HASH_BMFF, - /** Collection hash data. */ - @SerialName("c2pa.hash.collection") + /** Collection hash data. [c2pa-rs] */ + @SerialName("c2pa.hash.collection.data") HASH_COLLECTION, - /** Soft binding assertion. */ + /** Icon assertion. [c2pa-rs] */ + @SerialName("c2pa.icon") + ICON, + + /** Ingredient assertion (base label, auto-versioned by SDK). [c2pa-rs] */ + @SerialName("c2pa.ingredient") + INGREDIENT, + + /** JSON-LD metadata assertion. [c2pa-rs] */ + @SerialName("c2pa.metadata") + METADATA, + + /** Soft binding assertion. [c2pa-rs] */ @SerialName("c2pa.soft-binding") SOFT_BINDING, - /** Cloud data assertion. */ - @SerialName("c2pa.cloud-data") - CLOUD_DATA, - - /** Thumbnail claim assertion. */ + /** Thumbnail claim assertion. [c2pa-rs] */ @SerialName("c2pa.thumbnail.claim") THUMBNAIL_CLAIM, - /** Ingredient thumbnail assertion. */ + /** Ingredient thumbnail assertion. [c2pa-rs] */ @SerialName("c2pa.thumbnail.ingredient") THUMBNAIL_INGREDIENT, - /** Depthmap assertion. */ - @SerialName("c2pa.depthmap") - DEPTHMAP, + /** Time-stamp assertion. [c2pa-rs] */ + @SerialName("c2pa.time-stamp") + TIME_STAMP, - /** Training/Mining assertion. */ + /** Training/Mining assertion. [spec-only, not in c2pa-rs] */ @SerialName("c2pa.training-mining") TRAINING_MINING, - /** EXIF metadata assertion. */ + /** Font information assertion. [spec-only, not in c2pa-rs] */ + @SerialName("font.info") + FONT_INFO, + + /** EXIF metadata assertion (deprecated). [c2pa-rs] */ @SerialName("stds.exif") EXIF, - /** Schema.org Creative Work assertion. */ + /** Schema.org Creative Work assertion (deprecated). [c2pa-rs] */ @SerialName("stds.schema-org.CreativeWork") CREATIVE_WORK, - /** IPTC photo metadata assertion. */ + /** Schema.org Claim Review assertion. [c2pa-rs] */ + @SerialName("stds.schema-org.ClaimReview") + CLAIM_REVIEW, + + /** IPTC photo metadata assertion (deprecated). [c2pa-rs] */ @SerialName("stds.iptc.photo-metadata") IPTC_PHOTO_METADATA, - /** ISO location assertion. */ + /** ISO location assertion. [spec-only, not in c2pa-rs] */ @SerialName("stds.iso.location.v1") ISO_LOCATION, - /** CAWG identity assertion. */ - @SerialName("cawg.identity") - CAWG_IDENTITY, + /** CAWG metadata assertion. [c2pa-rs] */ + @SerialName("cawg.metadata") + CAWG_METADATA, - /** CAWG AI training and data mining assertion. */ - @SerialName("cawg.ai_training_and_data_mining") + /** CAWG training and data mining assertion. [spec-only, not in c2pa-rs] */ + @SerialName("cawg.training-mining") CAWG_AI_TRAINING, + ; + + /** Returns the serialized label string for this assertion type. */ + fun serialName(): String = when (this) { + ACTIONS -> "c2pa.actions" + ACTIONS_V2 -> "c2pa.actions.v2" + ASSERTION_METADATA -> "c2pa.assertion.metadata" + ASSET_REF -> "c2pa.asset-ref" + ASSET_TYPE -> "c2pa.asset-type" + ASSET_TYPE_V2 -> "c2pa.asset-type.v2" + CERTIFICATE_STATUS -> "c2pa.certificate-status" + CLOUD_DATA -> "c2pa.cloud-data" + DEPTHMAP -> "c2pa.depthmap" + DEPTHMAP_GDEPTH -> "c2pa.depthmap.GDepth" + EMBEDDED_DATA -> "c2pa.embedded-data" + HASH_DATA -> "c2pa.hash.data" + HASH_BOXES -> "c2pa.hash.boxes" + HASH_BMFF -> "c2pa.hash.bmff" + HASH_COLLECTION -> "c2pa.hash.collection.data" + ICON -> "c2pa.icon" + INGREDIENT -> "c2pa.ingredient" + METADATA -> "c2pa.metadata" + SOFT_BINDING -> "c2pa.soft-binding" + THUMBNAIL_CLAIM -> "c2pa.thumbnail.claim" + THUMBNAIL_INGREDIENT -> "c2pa.thumbnail.ingredient" + TIME_STAMP -> "c2pa.time-stamp" + TRAINING_MINING -> "c2pa.training-mining" + FONT_INFO -> "font.info" + EXIF -> "stds.exif" + CREATIVE_WORK -> "stds.schema-org.CreativeWork" + CLAIM_REVIEW -> "stds.schema-org.ClaimReview" + IPTC_PHOTO_METADATA -> "stds.iptc.photo-metadata" + ISO_LOCATION -> "stds.iso.location.v1" + CAWG_METADATA -> "cawg.metadata" + CAWG_AI_TRAINING -> "cawg.training-mining" + } } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/Time.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/Time.kt index 379cb29..3dccca1 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/Time.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/Time.kt @@ -12,6 +12,7 @@ each license. package org.contentauth.c2pa.manifest +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -30,4 +31,6 @@ data class Time( val start: String? = null, val end: String? = null, val type: TimeType? = null, + @SerialName("end_inclusivity") + val endInclusivity: String? = null, ) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/TimeType.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/TimeType.kt index d5ca75b..acadcbb 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/TimeType.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/TimeType.kt @@ -25,4 +25,8 @@ enum class TimeType { /** Normal Play Time format (npt). */ @SerialName("npt") NPT, + + /** Wall clock time format using RFC 3339 timestamps. */ + @SerialName("wallClock") + WALL_CLOCK, } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationStatusCode.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationStatusCode.kt index 842ee8e..b64493b 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationStatusCode.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationStatusCode.kt @@ -16,170 +16,439 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Validation status codes as defined in the C2PA specification. + * Validation status codes as defined in the C2PA 2.3 specification (Section 15). * * These codes indicate the result of various validation checks performed on manifests. + * Codes are organized into three categories: success, informational, and failure. * * @see ValidationStatus */ @Serializable enum class ValidationStatusCode { - // Success codes - @SerialName("claimSignature.validated") - CLAIM_SIGNATURE_VALIDATED, - @SerialName("signingCredential.trusted") - SIGNING_CREDENTIAL_TRUSTED, + // --- Success codes --- - @SerialName("timeStamp.trusted") - TIMESTAMP_TRUSTED, + /** The assertion is accessible for validation. */ + @SerialName("assertion.accessible") + ASSERTION_ACCESSIBLE, - @SerialName("assertion.dataHash.match") - ASSERTION_DATA_HASH_MATCH, + /** The alternative content representation hash matches. */ + @SerialName("assertion.alternativeContentRepresentation.match") + ASSERTION_ALT_CONTENT_MATCH, + /** The BMFF hash matches the asset. */ @SerialName("assertion.bmffHash.match") ASSERTION_BMFF_HASH_MATCH, + /** The box hash matches the asset. */ @SerialName("assertion.boxesHash.match") ASSERTION_BOXES_HASH_MATCH, + /** The collection hash matches. */ @SerialName("assertion.collectionHash.match") ASSERTION_COLLECTION_HASH_MATCH, + /** The data hash matches the asset. */ + @SerialName("assertion.dataHash.match") + ASSERTION_DATA_HASH_MATCH, + + /** The hashed URI reference matches. */ @SerialName("assertion.hashedURI.match") ASSERTION_HASHED_URI_MATCH, - @SerialName("assertion.ingredientMatch") - ASSERTION_INGREDIENT_MATCH, + /** The multi-asset hash matches. */ + @SerialName("assertion.multiAssetHash.match") + ASSERTION_MULTI_ASSET_HASH_MATCH, - @SerialName("assertion.accessible") - ASSERTION_ACCESSIBLE, + /** The claim signature is within its validity period. */ + @SerialName("claimSignature.insideValidity") + CLAIM_SIGNATURE_INSIDE_VALIDITY, - // Failure codes - @SerialName("assertion.dataHash.mismatch") - ASSERTION_DATA_HASH_MISMATCH, + /** The claim signature has been validated. */ + @SerialName("claimSignature.validated") + CLAIM_SIGNATURE_VALIDATED, + + /** The ingredient's claim signature has been validated. */ + @SerialName("ingredient.claimSignature.validated") + INGREDIENT_CLAIM_SIGNATURE_VALIDATED, + + /** The ingredient's manifest has been validated. */ + @SerialName("ingredient.manifest.validated") + INGREDIENT_MANIFEST_VALIDATED, + + /** The signing credential's OCSP status is not revoked. */ + @SerialName("signingCredential.ocsp.notRevoked") + SIGNING_CREDENTIAL_OCSP_NOT_REVOKED, + + /** The signing credential is trusted. */ + @SerialName("signingCredential.trusted") + SIGNING_CREDENTIAL_TRUSTED, + + /** The timestamp is trusted. */ + @SerialName("timeStamp.trusted") + TIMESTAMP_TRUSTED, + + /** The timestamp has been validated. */ + @SerialName("timeStamp.validated") + TIMESTAMP_VALIDATED, + + // --- Informational codes --- + + /** The algorithm used is deprecated. */ + @SerialName("algorithm.deprecated") + ALGORITHM_DEPRECATED, + + /** The BMFF hash has additional exclusions present. */ + @SerialName("assertion.bmffHash.additionalExclusionsPresent") + ASSERTION_BMFF_HASH_ADDITIONAL_EXCLUSIONS, + + /** The box hash has additional exclusions present. */ + @SerialName("assertion.boxesHash.additionalExclusionsPresent") + ASSERTION_BOXES_HASH_ADDITIONAL_EXCLUSIONS, + + /** The data hash has additional exclusions present. */ + @SerialName("assertion.dataHash.additionalExclusionsPresent") + ASSERTION_DATA_HASH_ADDITIONAL_EXCLUSIONS, + + /** The ingredient has unknown provenance. */ + @SerialName("ingredient.unknownProvenance") + INGREDIENT_UNKNOWN_PROVENANCE, + + /** The OCSP responder for the signing credential is inaccessible. */ + @SerialName("signingCredential.ocsp.inaccessible") + SIGNING_CREDENTIAL_OCSP_INACCESSIBLE, + + /** OCSP checking was skipped for the signing credential. */ + @SerialName("signingCredential.ocsp.skipped") + SIGNING_CREDENTIAL_OCSP_SKIPPED, + + /** The OCSP status of the signing credential is unknown. */ + @SerialName("signingCredential.ocsp.unknown") + SIGNING_CREDENTIAL_OCSP_UNKNOWN, + + /** The time of signing is within the credential validity period. */ + @SerialName("timeOfSigning.insideValidity") + TIME_OF_SIGNING_INSIDE_VALIDITY, + + /** The time of signing is outside the credential validity period. */ + @SerialName("timeOfSigning.outsideValidity") + TIME_OF_SIGNING_OUTSIDE_VALIDITY, + + /** The timestamp credential is invalid. */ + @SerialName("timeStamp.credentialInvalid") + TIMESTAMP_CREDENTIAL_INVALID, + + /** The timestamp is malformed. */ + @SerialName("timeStamp.malformed") + TIMESTAMP_MALFORMED, + + /** The timestamp does not match. */ + @SerialName("timeStamp.mismatch") + TIMESTAMP_MISMATCH, + + /** The timestamp is outside its validity period. */ + @SerialName("timeStamp.outsideValidity") + TIMESTAMP_OUTSIDE_VALIDITY, + + /** The timestamp is untrusted. */ + @SerialName("timeStamp.untrusted") + TIMESTAMP_UNTRUSTED, + + // --- Failure codes --- + + /** The algorithm used is unsupported. */ + @SerialName("algorithm.unsupported") + ALGORITHM_UNSUPPORTED, + + /** The action assertion has an ingredient mismatch. */ + @SerialName("assertion.action.ingredientMismatch") + ASSERTION_ACTION_INGREDIENT_MISMATCH, + + /** The action assertion is malformed. */ + @SerialName("assertion.action.malformed") + ASSERTION_ACTION_MALFORMED, + + /** The action assertion has missing information. */ + @SerialName("assertion.action.missing") + ASSERTION_ACTION_MISSING, + + /** An action assertion was redacted. */ + @SerialName("assertion.action.redacted") + ASSERTION_ACTION_REDACTED, + + /** The action assertion has a redaction mismatch. */ + @SerialName("assertion.action.redactionMismatch") + ASSERTION_ACTION_REDACTION_MISMATCH, + + /** The action assertion is missing a required soft binding. */ + @SerialName("assertion.action.softBindingMissing") + ASSERTION_ACTION_SOFT_BINDING_MISSING, + + /** The alternative content representation is malformed. */ + @SerialName("assertion.alternativeContentRepresentation.malformed") + ASSERTION_ALT_CONTENT_MALFORMED, + /** The alternative content representation hash does not match. */ + @SerialName("assertion.alternativeContentRepresentation.hashMismatch") + ASSERTION_ALT_CONTENT_HASH_MISMATCH, + + /** The alternative content representation is missing. */ + @SerialName("assertion.alternativeContentRepresentation.missing") + ASSERTION_ALT_CONTENT_MISSING, + + /** The BMFF hash is malformed. */ + @SerialName("assertion.bmffHash.malformed") + ASSERTION_BMFF_HASH_MALFORMED, + + /** The BMFF hash does not match. */ @SerialName("assertion.bmffHash.mismatch") ASSERTION_BMFF_HASH_MISMATCH, + /** The box hash is malformed. */ + @SerialName("assertion.boxesHash.malformed") + ASSERTION_BOXES_HASH_MALFORMED, + + /** The box hash does not match. */ @SerialName("assertion.boxesHash.mismatch") ASSERTION_BOXES_HASH_MISMATCH, + /** An unknown box was encountered in the box hash. */ + @SerialName("assertion.boxesHash.unknownBox") + ASSERTION_BOXES_HASH_UNKNOWN_BOX, + + /** The CBOR assertion data is invalid. */ + @SerialName("assertion.cbor.invalid") + ASSERTION_CBOR_INVALID, + + /** Cloud data assertion has incorrect actions reference. */ + @SerialName("assertion.cloud-data.actions") + ASSERTION_CLOUD_DATA_ACTIONS, + + /** Cloud data assertion has incorrect hard binding reference. */ + @SerialName("assertion.cloud-data.hardBinding") + ASSERTION_CLOUD_DATA_HARD_BINDING, + + /** Cloud data assertion label does not match. */ + @SerialName("assertion.cloud-data.labelMismatch") + ASSERTION_CLOUD_DATA_LABEL_MISMATCH, + + /** Cloud data assertion is malformed. */ + @SerialName("assertion.cloud-data.malformed") + ASSERTION_CLOUD_DATA_MALFORMED, + + /** The collection hash has an incorrect file count. */ + @SerialName("assertion.collectionHash.incorrectFileCount") + ASSERTION_COLLECTION_HASH_INCORRECT_FILE_COUNT, + + /** The collection hash has an invalid URI. */ + @SerialName("assertion.collectionHash.invalidURI") + ASSERTION_COLLECTION_HASH_INVALID_URI, + + /** The collection hash is malformed. */ + @SerialName("assertion.collectionHash.malformed") + ASSERTION_COLLECTION_HASH_MALFORMED, + + /** The collection hash does not match. */ @SerialName("assertion.collectionHash.mismatch") ASSERTION_COLLECTION_HASH_MISMATCH, - @SerialName("assertion.hashedURI.mismatch") - ASSERTION_HASHED_URI_MISMATCH, + /** The data hash is malformed. */ + @SerialName("assertion.dataHash.malformed") + ASSERTION_DATA_HASH_MALFORMED, - @SerialName("assertion.missing") - ASSERTION_MISSING, + /** The data hash does not match. */ + @SerialName("assertion.dataHash.mismatch") + ASSERTION_DATA_HASH_MISMATCH, - @SerialName("assertion.multipleHardBindings") - ASSERTION_MULTIPLE_HARD_BINDINGS, + /** The external reference has incorrect actions. */ + @SerialName("assertion.external-reference.actions") + ASSERTION_EXTERNAL_REFERENCE_ACTIONS, - @SerialName("assertion.undeclaredHashedURI") - ASSERTION_UNDECLARED_HASHED_URI, + /** The external reference was created incorrectly. */ + @SerialName("assertion.external-reference.created") + ASSERTION_EXTERNAL_REFERENCE_CREATED, - @SerialName("assertion.requiredMissing") - ASSERTION_REQUIRED_MISSING, + /** The external reference has incorrect hard binding. */ + @SerialName("assertion.external-reference.hardBinding") + ASSERTION_EXTERNAL_REFERENCE_HARD_BINDING, - @SerialName("assertion.inaccessible") - ASSERTION_INACCESSIBLE, + /** The external reference is malformed. */ + @SerialName("assertion.external-reference.malformed") + ASSERTION_EXTERNAL_REFERENCE_MALFORMED, - @SerialName("assertion.cloudData.hardBinding") - ASSERTION_CLOUD_DATA_HARD_BINDING, + /** A hard binding assertion was redacted. */ + @SerialName("assertion.hardBinding.redacted") + ASSERTION_HARD_BINDING_REDACTED, - @SerialName("assertion.cloudData.actions") - ASSERTION_CLOUD_DATA_ACTIONS, + /** The hashed URI does not match. */ + @SerialName("assertion.hashedURI.mismatch") + ASSERTION_HASHED_URI_MISMATCH, + + /** The assertion is inaccessible. */ + @SerialName("assertion.inaccessible") + ASSERTION_INACCESSIBLE, - @SerialName("assertion.cloudData.mismatch") - ASSERTION_CLOUD_DATA_MISMATCH, + /** The ingredient assertion is malformed. */ + @SerialName("assertion.ingredient.malformed") + ASSERTION_INGREDIENT_MALFORMED, + /** The JSON assertion data is invalid. */ @SerialName("assertion.json.invalid") ASSERTION_JSON_INVALID, - @SerialName("assertion.cbor.invalid") - ASSERTION_CBOR_INVALID, + /** The assertion is missing. */ + @SerialName("assertion.missing") + ASSERTION_MISSING, - @SerialName("assertion.action.ingredientMismatch") - ASSERTION_ACTION_INGREDIENT_MISMATCH, + /** The multi-asset hash is malformed. */ + @SerialName("assertion.multiAssetHash.malformed") + ASSERTION_MULTI_ASSET_HASH_MALFORMED, - @SerialName("assertion.action.missing") - ASSERTION_ACTION_MISSING, + /** The multi-asset hash has a missing part. */ + @SerialName("assertion.multiAssetHash.missingPart") + ASSERTION_MULTI_ASSET_HASH_MISSING_PART, + + /** The multi-asset hash does not match. */ + @SerialName("assertion.multiAssetHash.mismatch") + ASSERTION_MULTI_ASSET_HASH_MISMATCH, + + /** Multiple hard bindings were found. */ + @SerialName("assertion.multipleHardBindings") + ASSERTION_MULTIPLE_HARD_BINDINGS, + + /** The assertion was not redacted as expected. */ + @SerialName("assertion.notRedacted") + ASSERTION_NOT_REDACTED, - @SerialName("assertion.action.redactionMissing") - ASSERTION_ACTION_REDACTION_MISSING, + /** The assertion is outside the manifest. */ + @SerialName("assertion.outsideManifest") + ASSERTION_OUTSIDE_MANIFEST, + /** An assertion attempted to redact itself. */ @SerialName("assertion.selfRedacted") ASSERTION_SELF_REDACTED, - @SerialName("claim.missing") - CLAIM_MISSING, + /** The assertion timestamp is malformed. */ + @SerialName("assertion.timestamp.malformed") + ASSERTION_TIMESTAMP_MALFORMED, - @SerialName("claim.multiple") - CLAIM_MULTIPLE, + /** An undeclared assertion was found. */ + @SerialName("assertion.undeclared") + ASSERTION_UNDECLARED, + /** The claim CBOR data is invalid. */ + @SerialName("claim.cbor.invalid") + CLAIM_CBOR_INVALID, + + /** The claim is missing required hard bindings. */ @SerialName("claim.hardBindings.missing") CLAIM_HARD_BINDINGS_MISSING, - @SerialName("claim.required.missing") - CLAIM_REQUIRED_MISSING, + /** The claim is malformed. */ + @SerialName("claim.malformed") + CLAIM_MALFORMED, - @SerialName("claim.cbor.invalid") - CLAIM_CBOR_INVALID, + /** The claim is missing. */ + @SerialName("claim.missing") + CLAIM_MISSING, + + /** Multiple claims were found. */ + @SerialName("claim.multiple") + CLAIM_MULTIPLE, + /** The claim signature does not match. */ @SerialName("claimSignature.mismatch") CLAIM_SIGNATURE_MISMATCH, + /** The claim signature is missing. */ @SerialName("claimSignature.missing") CLAIM_SIGNATURE_MISSING, - @SerialName("manifest.missing") - MANIFEST_MISSING, + /** The claim signature is outside its validity period. */ + @SerialName("claimSignature.outsideValidity") + CLAIM_SIGNATURE_OUTSIDE_VALIDITY, - @SerialName("manifest.multipleParents") - MANIFEST_MULTIPLE_PARENTS, + /** A general error occurred. */ + @SerialName("general.error") + GENERAL_ERROR, - @SerialName("manifest.updateWrongParents") - MANIFEST_UPDATE_WRONG_PARENTS, + /** A hashed URI reference is missing. */ + @SerialName("hashedURI.missing") + HASHED_URI_MISSING, - @SerialName("manifest.inaccessible") - MANIFEST_INACCESSIBLE, + /** A hashed URI reference does not match. */ + @SerialName("hashedURI.mismatch") + HASHED_URI_MISMATCH, + + /** The ingredient's claim signature is missing. */ + @SerialName("ingredient.claimSignature.missing") + INGREDIENT_CLAIM_SIGNATURE_MISSING, + + /** The ingredient's claim signature does not match. */ + @SerialName("ingredient.claimSignature.mismatch") + INGREDIENT_CLAIM_SIGNATURE_MISMATCH, + /** The ingredient's hashed URI does not match. */ @SerialName("ingredient.hashedURI.mismatch") INGREDIENT_HASHED_URI_MISMATCH, - @SerialName("signingCredential.untrusted") - SIGNING_CREDENTIAL_UNTRUSTED, + /** The ingredient's manifest is missing. */ + @SerialName("ingredient.manifest.missing") + INGREDIENT_MANIFEST_MISSING, - @SerialName("signingCredential.invalid") - SIGNING_CREDENTIAL_INVALID, + /** The ingredient's manifest does not match. */ + @SerialName("ingredient.manifest.mismatch") + INGREDIENT_MANIFEST_MISMATCH, - @SerialName("signingCredential.revoked") - SIGNING_CREDENTIAL_REVOKED, + /** A compressed manifest is invalid. */ + @SerialName("manifest.compressed.invalid") + MANIFEST_COMPRESSED_INVALID, - @SerialName("signingCredential.expired") - SIGNING_CREDENTIAL_EXPIRED, + /** The manifest is inaccessible. */ + @SerialName("manifest.inaccessible") + MANIFEST_INACCESSIBLE, - @SerialName("timeStamp.mismatch") - TIMESTAMP_MISMATCH, + /** The manifest is missing. */ + @SerialName("manifest.missing") + MANIFEST_MISSING, - @SerialName("timeStamp.untrusted") - TIMESTAMP_UNTRUSTED, + /** The manifest has multiple parents. */ + @SerialName("manifest.multipleParents") + MANIFEST_MULTIPLE_PARENTS, - @SerialName("timeStamp.outsideValidity") - TIMESTAMP_OUTSIDE_VALIDITY, + /** The manifest timestamp is invalid. */ + @SerialName("manifest.timestamp.invalid") + MANIFEST_TIMESTAMP_INVALID, - @SerialName("algorithm.unsupported") - ALGORITHM_UNSUPPORTED, + /** The manifest timestamp has wrong parents. */ + @SerialName("manifest.timestamp.wrongParents") + MANIFEST_TIMESTAMP_WRONG_PARENTS, - @SerialName("general.error") - GENERAL_ERROR, + /** The manifest update is invalid. */ + @SerialName("manifest.update.invalid") + MANIFEST_UPDATE_INVALID, - // Additional status codes - @SerialName("assertion.redactedUriMismatch") - ASSERTION_REDACTED_URI_MISMATCH, + /** The manifest update has wrong parents. */ + @SerialName("manifest.update.wrongParents") + MANIFEST_UPDATE_WRONG_PARENTS, - @SerialName("assertion.notRedactable") - ASSERTION_NOT_REDACTABLE, + /** The signing credential is expired. */ + @SerialName("signingCredential.expired") + SIGNING_CREDENTIAL_EXPIRED, + + /** The signing credential is invalid. */ + @SerialName("signingCredential.invalid") + SIGNING_CREDENTIAL_INVALID, + + /** The signing credential has been revoked (via OCSP). */ + @SerialName("signingCredential.ocsp.revoked") + SIGNING_CREDENTIAL_OCSP_REVOKED, + + /** The signing credential's OCSP revocation status (legacy code). */ + @SerialName("signingCredential.revoked") + SIGNING_CREDENTIAL_REVOKED, + + /** The signing credential is untrusted. */ + @SerialName("signingCredential.untrusted") + SIGNING_CREDENTIAL_UNTRUSTED, } diff --git a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt index 8d0c374..f310ccf 100644 --- a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt +++ b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt @@ -241,6 +241,11 @@ private suspend fun runAllTests(context: Context): List = withContex results.add(manifestTests.testCreatedFactory()) results.add(manifestTests.testAllValidationStatusCodes()) results.add(manifestTests.testAllDigitalSourceTypes()) + results.add(manifestTests.testAssertionsWithBuilder()) + results.add(manifestTests.testCustomGatheredAssertionWithBuilder()) + results.add(manifestTests.testCustomAssertionLabelValidation()) + results.add(manifestTests.testImageRegionTypeToTypeString()) + results.add(manifestTests.testStandardAssertionLabelSerialNames()) // Settings Validator Tests val settingsValidatorTests = AppSettingsValidatorTests(context) diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt index 1c69e01..5e9247f 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt @@ -14,6 +14,7 @@ package org.contentauth.c2pa.test.shared import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive import org.contentauth.c2pa.Action import org.contentauth.c2pa.Builder import org.contentauth.c2pa.BuilderIntent @@ -925,8 +926,8 @@ abstract class BuilderTests : TestBase() { builder.addAction( Action( action = "com.example.custom_action", - softwareAgent = "CustomTool/2.0", - parameters = mapOf("key1" to "value1", "key2" to "value2"), + softwareAgent = JsonPrimitive("CustomTool/2.0"), + parameters = mapOf("key1" to JsonPrimitive("value1"), "key2" to JsonPrimitive("value2")), ), ) diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestTests.kt index 67b827c..99d0c73 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestTests.kt @@ -26,6 +26,7 @@ import org.contentauth.c2pa.manifest.Frame import org.contentauth.c2pa.manifest.HashedUri import org.contentauth.c2pa.manifest.ImageRegionType import org.contentauth.c2pa.manifest.Ingredient +import org.contentauth.c2pa.manifest.StandardAssertionLabel import org.contentauth.c2pa.manifest.IngredientDeltaValidationResult import org.contentauth.c2pa.manifest.Item import org.contentauth.c2pa.manifest.ManifestDefinition @@ -50,6 +51,10 @@ import org.contentauth.c2pa.manifest.ValidationStatus import org.contentauth.c2pa.manifest.ValidationStatusCode import org.contentauth.c2pa.manifest.Relationship import org.contentauth.c2pa.manifest.TrainingMiningEntry +import org.contentauth.c2pa.manifest.CawgTrainingMiningEntry +import org.contentauth.c2pa.manifest.ManifestValidator +import org.contentauth.c2pa.manifest.SettingsValidator +import kotlinx.serialization.json.JsonElement import org.contentauth.c2pa.Builder import org.contentauth.c2pa.ByteArrayStream import org.contentauth.c2pa.C2PA @@ -560,74 +565,66 @@ abstract class ManifestTests : TestBase() { val jsonString = manifest.toJson() // Try to create a Builder from our manifest JSON - val builder = Builder.fromJson(jsonString) + val outputFile = File.createTempFile("manifest-builder-test", ".jpg") try { - val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val sourceStream = ByteArrayStream(sourceImageData) - - val outputFile = File.createTempFile("manifest-builder-test", ".jpg") - val destStream = FileStream(outputFile) - try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - - val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) - val signer = Signer.fromInfo(signerInfo) - - try { - builder.sign("image/jpeg", sourceStream, destStream, signer) - - // Read back and verify - val readManifest = C2PA.readFile(outputFile.absolutePath) - val json = JSONObject(readManifest) - - if (!json.has("manifests")) { - return@runTest TestResult( - "Manifest with Builder", - false, - "Signed file has no manifests", - ) - } - - // Verify our title made it through - val manifests = json.getJSONObject("manifests") - val keys = manifests.keys() - if (!keys.hasNext()) { - return@runTest TestResult( - "Manifest with Builder", - false, - "No manifest entries found", - ) - } - - val firstManifest = manifests.getJSONObject(keys.next()) - val title = firstManifest.optString("title", "") - - if (title != "Builder Integration Test") { - return@runTest TestResult( - "Manifest with Builder", - false, - "Title mismatch", - "Expected: 'Builder Integration Test', Got: '$title'", - ) + Builder.fromJson(jsonString).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(outputFile).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + + val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) + Signer.fromInfo(signerInfo).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + // Read back and verify + val readManifest = C2PA.readFile(outputFile.absolutePath) + val json = JSONObject(readManifest) + + if (!json.has("manifests")) { + return@runTest TestResult( + "Manifest with Builder", + false, + "Signed file has no manifests", + ) + } + + // Verify our title made it through + val manifests = json.getJSONObject("manifests") + val keys = manifests.keys() + if (!keys.hasNext()) { + return@runTest TestResult( + "Manifest with Builder", + false, + "No manifest entries found", + ) + } + + val firstManifest = manifests.getJSONObject(keys.next()) + val title = firstManifest.optString("title", "") + + if (title != "Builder Integration Test") { + return@runTest TestResult( + "Manifest with Builder", + false, + "Title mismatch", + "Expected: 'Builder Integration Test', Got: '$title'", + ) + } + + TestResult( + "Manifest with Builder", + true, + "ManifestDefinition successfully used with Builder", + "Signed file: ${outputFile.length()} bytes", + ) + } } - - TestResult( - "Manifest with Builder", - true, - "ManifestDefinition successfully used with Builder", - "Signed file: ${outputFile.length()} bytes", - ) - } finally { - signer.close() } - } finally { - sourceStream.close() - destStream.close() - outputFile.delete() } } finally { - builder.close() + outputFile.delete() } } catch (e: Exception) { TestResult( @@ -1156,8 +1153,7 @@ abstract class ManifestTests : TestBase() { // Verify it works with Builder val jsonString = manifest.toJson() - val builder = Builder.fromJson(jsonString) - builder.close() + Builder.fromJson(jsonString).use { } TestResult( "Created Factory", @@ -1279,8 +1275,1563 @@ abstract class ManifestTests : TestBase() { } /** - * Helper function to clone and compare a manifest via JSON. + * Tests CAWG training/mining assertion type. + */ + suspend fun testCawgTrainingMiningAssertion(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWG Training Mining Assertion") { + try { + val cawgTrainingMining = AssertionDefinition.cawgTrainingMining( + listOf( + CawgTrainingMiningEntry( + use = "notAllowed", + constraintInfo = "No AI training permitted", + aiModelLearningType = "machineLearning", + ), + CawgTrainingMiningEntry( + use = "constrained", + constraintInfo = "https://example.com/terms", + aiMiningType = "dataAggregation", + ), + ), + ) + + val manifest = ManifestDefinition( + title = "CAWG Training Mining Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf(cawgTrainingMining), + ) + + val jsonString = manifest.toJson() + val parsed = ManifestDefinition.fromJson(jsonString) + + val assertion = parsed.assertions.first() + if (assertion !is AssertionDefinition.CawgTrainingMining) { + return@runTest TestResult( + "CAWG Training Mining Assertion", + false, + "Assertion is not CawgTrainingMining", + "Got: ${assertion::class.simpleName}", + ) + } + + if (assertion.entries.size != 2) { + return@runTest TestResult( + "CAWG Training Mining Assertion", + false, + "Entry count mismatch", + "Expected 2, got ${assertion.entries.size}", + ) + } + + val firstEntry = assertion.entries.first() + if (firstEntry.use != "notAllowed" || firstEntry.aiModelLearningType != "machineLearning") { + return@runTest TestResult( + "CAWG Training Mining Assertion", + false, + "First entry data mismatch", + "use: ${firstEntry.use}, aiModelLearningType: ${firstEntry.aiModelLearningType}", + ) + } + + TestResult( + "CAWG Training Mining Assertion", + true, + "CAWG training/mining assertion serializes correctly", + "Entries: ${assertion.entries.size}", + ) + } catch (e: Exception) { + TestResult( + "CAWG Training Mining Assertion", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests ManifestValidator for basic validation. + */ + suspend fun testManifestValidator(): TestResult = withContext(Dispatchers.IO) { + runTest("Manifest Validator") { + try { + // Test 1: Valid manifest should pass + val validManifest = ManifestDefinition( + title = "Valid Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion.created(DigitalSourceType.DIGITAL_CAPTURE)), + ), + ), + ) + + val validResult = ManifestValidator.validate(validManifest) + if (validResult.hasErrors()) { + return@runTest TestResult( + "Manifest Validator", + false, + "Valid manifest should not have errors", + "Errors: ${validResult.errors}", + ) + } + + // Test 2: Empty title should produce error + val emptyTitleManifest = ManifestDefinition( + title = "", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + ) + + val emptyTitleResult = ManifestValidator.validate(emptyTitleManifest) + if (!emptyTitleResult.hasErrors()) { + return@runTest TestResult( + "Manifest Validator", + false, + "Empty title should produce an error", + ) + } + + // Test 3: Missing claim_generator_info should produce error + val noClaimGenManifest = ManifestDefinition( + title = "Test", + claimGeneratorInfo = emptyList(), + ) + + val noClaimGenResult = ManifestValidator.validate(noClaimGenManifest) + if (!noClaimGenResult.hasErrors()) { + return@runTest TestResult( + "Manifest Validator", + false, + "Empty claim_generator_info should produce an error", + ) + } + + TestResult( + "Manifest Validator", + true, + "ManifestValidator correctly validates manifests", + ) + } catch (e: Exception) { + TestResult( + "Manifest Validator", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests manifest with mixed assertion types. + */ + suspend fun testMixedAssertionTypes(): TestResult = withContext(Dispatchers.IO) { + runTest("Mixed Assertion Types") { + try { + val manifest = ManifestDefinition( + title = "Mixed Assertions Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test", version = "1.0")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion.edited(softwareAgent = "TestApp/1.0")), + ), + AssertionDefinition.exif( + mapOf("Make" to JsonPrimitive("TestCamera")), + ), + AssertionDefinition.cawgTrainingMining( + listOf(CawgTrainingMiningEntry(use = "notAllowed")), + ), + ), + ingredients = listOf( + Ingredient.parent("Original Image", "image/jpeg"), + ), + ) + + val jsonString = manifest.toJson() + val parsed = ManifestDefinition.fromJson(jsonString) + + if (parsed.assertions.size != 3) { + return@runTest TestResult( + "Mixed Assertion Types", + false, + "Assertions count mismatch", + "Expected 3, got ${parsed.assertions.size}", + ) + } + + if (parsed.ingredients.size != 1) { + return@runTest TestResult( + "Mixed Assertion Types", + false, + "Ingredients count mismatch", + "Expected 1, got ${parsed.ingredients.size}", + ) + } + + val hasActions = parsed.assertions.any { it is AssertionDefinition.Actions } + val hasExif = parsed.assertions.any { it is AssertionDefinition.Exif } + val hasCawgTraining = parsed.assertions.any { it is AssertionDefinition.CawgTrainingMining } + + if (!hasActions || !hasExif || !hasCawgTraining) { + return@runTest TestResult( + "Mixed Assertion Types", + false, + "Missing expected assertion types", + "Actions: $hasActions, Exif: $hasExif, CawgTrainingMining: $hasCawgTraining", + ) + } + + TestResult( + "Mixed Assertion Types", + true, + "Manifest with mixed assertion types works correctly", + "Assertions: ${parsed.assertions.size}, Ingredients: ${parsed.ingredients.size}", + ) + } catch (e: Exception) { + TestResult( + "Mixed Assertion Types", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests ManifestDefinition.edited factory. + */ + suspend fun testEditedFactory(): TestResult = withContext(Dispatchers.IO) { + runTest("Edited Factory") { + try { + val manifest = ManifestDefinition.edited( + title = "Edited Photo", + claimGeneratorInfo = ClaimGeneratorInfo(name = "PhotoEditor", version = "2.0"), + parentIngredient = Ingredient.parent("Original.jpg", "image/jpeg"), + editActions = listOf( + ActionAssertion.edited(softwareAgent = "PhotoEditor/2.0"), + ActionAssertion( + action = PredefinedAction.CROPPED, + softwareAgent = "PhotoEditor/2.0", + ), + ), + ) + + val jsonString = manifest.toJson() + val parsed = ManifestDefinition.fromJson(jsonString) + + // Verify title + if (parsed.title != "Edited Photo") { + return@runTest TestResult( + "Edited Factory", + false, + "Title mismatch", + "Expected 'Edited Photo', got '${parsed.title}'", + ) + } + + // Verify ingredients + if (parsed.ingredients.size != 1) { + return@runTest TestResult( + "Edited Factory", + false, + "Should have one ingredient", + "Got: ${parsed.ingredients.size}", + ) + } + + val ingredient = parsed.ingredients.first() + if (ingredient.relationship != Relationship.PARENT_OF) { + return@runTest TestResult( + "Edited Factory", + false, + "Ingredient should be parent", + "Got: ${ingredient.relationship}", + ) + } + + // Verify actions + val actionsAssertion = parsed.assertions.firstOrNull() as? AssertionDefinition.Actions + if (actionsAssertion == null || actionsAssertion.actions.size != 2) { + return@runTest TestResult( + "Edited Factory", + false, + "Should have 2 actions", + "Got: ${actionsAssertion?.actions?.size ?: 0}", + ) + } + + TestResult( + "Edited Factory", + true, + "ManifestDefinition.edited works correctly", + ) + } catch (e: Exception) { + TestResult( + "Edited Factory", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests that the default Builder.fromJson correctly handles assertion labels. + * + * The SDK uses `created_assertion_labels` to determine which assertions are + * "created" (attributed to the signer) vs "gathered". Labels not in that list + * are automatically treated as gathered by the SDK. + */ + suspend fun testAssertionsWithBuilder(): TestResult = withContext(Dispatchers.IO) { + runTest("Assertions with Builder") { + try { + val manifest = ManifestDefinition( + title = "Builder Assertions Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "TestApp", version = "1.0")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion.created(DigitalSourceType.DIGITAL_CAPTURE)), + ), + AssertionDefinition.custom( + label = "com.test.custom", + data = buildJsonObject { put("test", "value") }, + ), + ), + ) + + val jsonString = manifest.toJson() + val outputFile = File.createTempFile("assertions-builder-test", ".jpg") + try { + Builder.fromJson(jsonString).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(outputFile).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) + Signer.fromInfo(signerInfo).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val readManifest = C2PA.readFile(outputFile.absolutePath) + val json = JSONObject(readManifest) + + if (!json.has("manifests")) { + return@runTest TestResult( + "Assertions with Builder", + false, + "Signed file has no manifests", + ) + } + + TestResult( + "Assertions with Builder", + true, + "Manifest with assertions signed successfully", + "File size: ${outputFile.length()} bytes", + ) + } + } + } + } + } finally { + outputFile.delete() + } + } catch (e: Exception) { + TestResult( + "Assertions with Builder", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Test that creates a signed file with a custom assertion that will be treated as gathered. + * + * The default Builder.fromJson automatically configures the SDK to place common assertions + * (c2pa.actions, c2pa.thumbnail.claim, etc.) in created_assertions. Labels not in the + * `created_assertion_labels` list are treated as gathered by the SDK. + * + * Note: cawg.identity assertions require a valid structure with referenced_assertions + * that can only be constructed by the SDK during signing. To test the gathered assertion + * mechanism, we use a custom assertion label instead. + */ + suspend fun testCustomGatheredAssertionWithBuilder(): TestResult = withContext(Dispatchers.IO) { + runTest("Custom Gathered Assertion with Builder") { + try { + val manifest = ManifestDefinition( + title = "Custom Gathered Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "c2pa-android", version = "1.0")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion.created(DigitalSourceType.DIGITAL_CAPTURE)), + ), + AssertionDefinition.custom( + label = "com.test.gathered-data", + data = buildJsonObject { + put("source", "test") + put("verified", true) + }, + ), + ), + ) + + val jsonString = manifest.toJson() + val outputFile = File(getContext().cacheDir, "custom_gathered_test_output.jpg") + Builder.fromJson(jsonString).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(outputFile).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) + Signer.fromInfo(signerInfo).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val readManifest = C2PA.readFile(outputFile.absolutePath) + val json = JSONObject(readManifest) + + if (!json.has("manifests")) { + return@runTest TestResult( + "Custom Gathered Assertion with Builder", + false, + "Signed file has no manifests", + ) + } + + TestResult( + "Custom Gathered Assertion with Builder", + true, + "Manifest with custom gathered assertion signed successfully", + "File size: ${outputFile.length()} bytes", + ) + } + } + } + } + } catch (e: Exception) { + TestResult( + "Custom Gathered Assertion with Builder", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests ManifestValidator for deprecated assertions and claim_version validation. */ + suspend fun testDeprecatedAssertionValidation(): TestResult = withContext(Dispatchers.IO) { + runTest("Deprecated Assertion Validation") { + try { + // Test 1: Deprecated EXIF assertion should generate warning + val manifestWithExif = ManifestDefinition( + title = "Test with Deprecated EXIF", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.exif( + mapOf("Make" to JsonPrimitive("TestCamera")), + ), + ), + ) + + val exifResult = ManifestValidator.validate(manifestWithExif) + val hasExifWarning = exifResult.warnings.any { it.contains("stds.exif") && it.contains("deprecated") } + if (!hasExifWarning) { + return@runTest TestResult( + "Deprecated Assertion Validation", + false, + "stds.exif should generate deprecation warning", + "Warnings: ${exifResult.warnings}", + ) + } + + // Test 2: Deprecated CreativeWork assertion should generate warning + val manifestWithCreativeWork = ManifestDefinition( + title = "Test with Deprecated CreativeWork", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.creativeWork( + mapOf("author" to JsonPrimitive("Test Author")), + ), + ), + ) + + val cwResult = ManifestValidator.validate(manifestWithCreativeWork) + val hasCwWarning = cwResult.warnings.any { + it.contains("stds.schema-org.CreativeWork") && it.contains("deprecated") + } + if (!hasCwWarning) { + return@runTest TestResult( + "Deprecated Assertion Validation", + false, + "stds.schema-org.CreativeWork should generate deprecation warning", + "Warnings: ${cwResult.warnings}", + ) + } + + // Test 3: Non-v2 claim_version should generate warning via JSON validation + val v1ManifestJson = """ + { + "title": "V1 Manifest", + "claim_generator": "test/1.0", + "claim_version": 1, + "assertions": [] + } + """.trimIndent() + + val v1Result = ManifestValidator.validateJson(v1ManifestJson, logWarnings = false) + val hasV1Warning = v1Result.warnings.any { + it.contains("claim_version is 1") || it.contains("Version 1") + } + if (!hasV1Warning) { + return@runTest TestResult( + "Deprecated Assertion Validation", + false, + "claim_version 1 should generate warning", + "Warnings: ${v1Result.warnings}", + ) + } + + // Test 4: Valid v2 manifest should not have claim_version warning + val v2ManifestJson = """ + { + "title": "V2 Manifest", + "claim_generator_info": [{"name": "test"}], + "claim_version": 2, + "assertions": [] + } + """.trimIndent() + + val v2Result = ManifestValidator.validateJson(v2ManifestJson, logWarnings = false) + val hasV2Warning = v2Result.warnings.any { it.contains("claim_version") } + if (hasV2Warning) { + return@runTest TestResult( + "Deprecated Assertion Validation", + false, + "claim_version 2 should not generate warning", + "Warnings: ${v2Result.warnings}", + ) + } + + TestResult( + "Deprecated Assertion Validation", + true, + "ManifestValidator correctly identifies deprecated assertions and claim_version issues", + "Tested: stds.exif, stds.schema-org.CreativeWork, claim_version 1 vs 2", + ) + } catch (e: Exception) { + TestResult( + "Deprecated Assertion Validation", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests all PredefinedAction values serialize correctly. + * + * This test verifies that every predefined action from the C2PA 2.3 specification + * can be serialized and deserialized correctly in an ActionAssertion. + */ + suspend fun testAllPredefinedActions(): TestResult = withContext(Dispatchers.IO) { + runTest("All Predefined Actions") { + try { + val allActions = PredefinedAction.entries + + for (predefinedAction in allActions) { + val manifest = ManifestDefinition( + title = "Action Test: ${predefinedAction.name}", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.actions( + listOf( + ActionAssertion( + action = predefinedAction, + softwareAgent = "TestApp/1.0", + ), + ), + ), + ), + ) + + val jsonString = manifest.toJson() + val parsed = ManifestDefinition.fromJson(jsonString) + + val actions = (parsed.assertions.first() as AssertionDefinition.Actions).actions + val parsedAction = actions.first().action + + // Verify the action value matches the predefined action's value + if (parsedAction != predefinedAction.value) { + return@runTest TestResult( + "All Predefined Actions", + false, + "Action mismatch for ${predefinedAction.name}", + "Expected: ${predefinedAction.value}, Got: $parsedAction", + ) + } + } + + TestResult( + "All Predefined Actions", + true, + "All ${allActions.size} predefined actions serialize correctly", + "Actions: ${allActions.map { it.name }.joinToString(", ")}", + ) + } catch (e: Exception) { + TestResult( + "All Predefined Actions", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests redactions field serialization. + * + * Per C2PA spec section 6.8, assertions can be redacted from ingredients. + * The redactions field contains a list of assertion URIs to redact. + */ + suspend fun testRedactions(): TestResult = withContext(Dispatchers.IO) { + runTest("Redactions") { + try { + val manifest = ManifestDefinition( + title = "Manifest with Redactions", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion.created(DigitalSourceType.DIGITAL_CAPTURE)), + ), + ), + ingredients = listOf( + Ingredient.parent("Parent with redacted assertions", "image/jpeg"), + ), + redactions = listOf( + "self#jumbf=/c2pa/urn:uuid:example/c2pa.assertions/c2pa.actions", + "self#jumbf=/c2pa/urn:uuid:example/c2pa.assertions/stds.exif", + ), + ) + + val jsonString = manifest.toJson() + val parsed = ManifestDefinition.fromJson(jsonString) + + // Capture to local variable for smart cast + val redactions = parsed.redactions + + // Verify redactions list is preserved + if (redactions == null || redactions.size != 2) { + return@runTest TestResult( + "Redactions", + false, + "Redactions list not preserved", + "Expected 2 redactions, got: ${redactions?.size ?: 0}", + ) + } + + // Verify specific redaction URIs + if (!redactions.contains("self#jumbf=/c2pa/urn:uuid:example/c2pa.assertions/c2pa.actions")) { + return@runTest TestResult( + "Redactions", + false, + "c2pa.actions redaction not found", + ) + } + + if (!redactions.contains("self#jumbf=/c2pa/urn:uuid:example/c2pa.assertions/stds.exif")) { + return@runTest TestResult( + "Redactions", + false, + "stds.exif redaction not found", + ) + } + + // Verify JSON contains redactions key + if (!jsonString.contains("\"redactions\"")) { + return@runTest TestResult( + "Redactions", + false, + "JSON does not contain redactions key", + ) + } + + TestResult( + "Redactions", + true, + "Redactions field serializes correctly", + "Redacted ${redactions.size} assertion URIs", + ) + } catch (e: Exception) { + TestResult( + "Redactions", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests all Relationship values (parentOf, componentOf, inputTo) serialize correctly. + * + * This test verifies that all three C2PA ingredient relationships work properly + * with both factory methods and direct enum usage. + */ + suspend fun testAllIngredientRelationships(): TestResult = withContext(Dispatchers.IO) { + runTest("All Ingredient Relationships") { + try { + val manifest = ManifestDefinition( + title = "Relationship Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion(action = PredefinedAction.EDITED)), + ), + ), + ingredients = listOf( + // Test parentOf via factory method + Ingredient.parent("Parent Image", "image/jpeg"), + // Test componentOf via factory method + Ingredient.component("Component Overlay", "image/png"), + // Test inputTo via factory method + Ingredient.inputTo("Input Reference", "image/jpeg"), + // Test direct enum usage for all relationships + Ingredient( + title = "Direct Parent", + relationship = Relationship.PARENT_OF, + ), + Ingredient( + title = "Direct Component", + relationship = Relationship.COMPONENT_OF, + ), + Ingredient( + title = "Direct Input", + relationship = Relationship.INPUT_TO, + ), + ), + ) + + val jsonString = manifest.toJson() + val parsed = ManifestDefinition.fromJson(jsonString) + + // Verify all 6 ingredients + if (parsed.ingredients.size != 6) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "Ingredient count mismatch", + "Expected 6, got ${parsed.ingredients.size}", + ) + } + + // Verify each relationship via factory + val parentViaFactory = parsed.ingredients.find { it.title == "Parent Image" } + val componentViaFactory = parsed.ingredients.find { it.title == "Component Overlay" } + val inputViaFactory = parsed.ingredients.find { it.title == "Input Reference" } + + if (parentViaFactory?.relationship != Relationship.PARENT_OF) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "parentOf factory failed", + "Got: ${parentViaFactory?.relationship}", + ) + } + + if (componentViaFactory?.relationship != Relationship.COMPONENT_OF) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "componentOf factory failed", + "Got: ${componentViaFactory?.relationship}", + ) + } + + if (inputViaFactory?.relationship != Relationship.INPUT_TO) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "inputTo factory failed", + "Got: ${inputViaFactory?.relationship}", + ) + } + + // Verify each relationship via direct enum + val parentDirect = parsed.ingredients.find { it.title == "Direct Parent" } + val componentDirect = parsed.ingredients.find { it.title == "Direct Component" } + val inputDirect = parsed.ingredients.find { it.title == "Direct Input" } + + if (parentDirect?.relationship != Relationship.PARENT_OF) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "parentOf direct enum failed", + "Got: ${parentDirect?.relationship}", + ) + } + + if (componentDirect?.relationship != Relationship.COMPONENT_OF) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "componentOf direct enum failed", + "Got: ${componentDirect?.relationship}", + ) + } + + if (inputDirect?.relationship != Relationship.INPUT_TO) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "inputTo direct enum failed", + "Got: ${inputDirect?.relationship}", + ) + } + + // Verify JSON contains all relationship strings + val hasParentOf = jsonString.contains("\"parentOf\"") + val hasComponentOf = jsonString.contains("\"componentOf\"") + val hasInputTo = jsonString.contains("\"inputTo\"") + + if (!hasParentOf || !hasComponentOf || !hasInputTo) { + return@runTest TestResult( + "All Ingredient Relationships", + false, + "JSON missing relationship strings", + "parentOf: $hasParentOf, componentOf: $hasComponentOf, inputTo: $hasInputTo", + ) + } + + TestResult( + "All Ingredient Relationships", + true, + "All 3 ingredient relationships (parentOf, componentOf, inputTo) serialize correctly", + "Tested via factory methods and direct enum usage", + ) + } catch (e: Exception) { + TestResult( + "All Ingredient Relationships", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + suspend fun testSettingsValidatorValid(): TestResult = withContext(Dispatchers.IO) { + runTest("Settings Validator - Valid") { + try { + val validSettings = """ + { + "version": 1, + "builder": { + "created_assertion_labels": ["c2pa.actions", "c2pa.thumbnail.claim"] + } + } + """.trimIndent() + + val result = SettingsValidator.validate(validSettings, logWarnings = false) + val success = result.isValid() && !result.hasErrors() + + TestResult( + "Settings Validator - Valid", + success, + if (success) { + "Valid settings pass validation" + } else { + "Valid settings should not have errors" + }, + "Errors: ${result.errors}, Warnings: ${result.warnings}", + ) + } catch (e: Exception) { + TestResult( + "Settings Validator - Valid", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testSettingsValidatorErrors(): TestResult = withContext(Dispatchers.IO) { + runTest("Settings Validator - Errors") { + try { + // Missing version + val noVersion = """{"builder": {}}""" + val noVersionResult = SettingsValidator.validate(noVersion, logWarnings = false) + val missingVersionDetected = noVersionResult.hasErrors() && + noVersionResult.errors.any { it.contains("version") } + + // Wrong version + val wrongVersion = """{"version": 99}""" + val wrongVersionResult = SettingsValidator.validate(wrongVersion, logWarnings = false) + val wrongVersionDetected = wrongVersionResult.hasErrors() && + wrongVersionResult.errors.any { it.contains("version") } + + // Invalid JSON + val invalidJson = "not json at all" + val invalidResult = SettingsValidator.validate(invalidJson, logWarnings = false) + val invalidJsonDetected = invalidResult.hasErrors() + + // Unknown top-level key + val unknownKey = """{"version": 1, "bogus_section": {}}""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + val unknownKeyDetected = unknownResult.hasWarnings() && + unknownResult.warnings.any { it.contains("bogus_section") } + + // Verify section with non-boolean + val badVerify = """{"version": 1, "verify": {"verify_trust": "yes"}}""" + val badVerifyResult = SettingsValidator.validate(badVerify, logWarnings = false) + val badVerifyDetected = badVerifyResult.hasErrors() && + badVerifyResult.errors.any { it.contains("verify_trust") } + + val success = missingVersionDetected && wrongVersionDetected && + invalidJsonDetected && unknownKeyDetected && badVerifyDetected + + TestResult( + "Settings Validator - Errors", + success, + if (success) { + "All error cases detected" + } else { + "Some error cases not detected" + }, + "Missing version: $missingVersionDetected, Wrong version: $wrongVersionDetected, " + + "Invalid JSON: $invalidJsonDetected, Unknown key: $unknownKeyDetected, " + + "Bad verify: $badVerifyDetected", + ) + } catch (e: Exception) { + TestResult( + "Settings Validator - Errors", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testSettingsValidatorBuilderSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Settings Validator - Builder Section") { + try { + // Valid builder with intent + val withIntent = """ + { + "version": 1, + "builder": { + "intent": {"Create": "digitalCapture"}, + "created_assertion_labels": ["c2pa.actions"] + } + } + """.trimIndent() + val intentResult = SettingsValidator.validate(withIntent, logWarnings = false) + val intentValid = intentResult.isValid() + + // Invalid intent string + val badIntent = """ + { + "version": 1, + "builder": { + "intent": "InvalidIntent" + } + } + """.trimIndent() + val badIntentResult = SettingsValidator.validate(badIntent, logWarnings = false) + val badIntentDetected = badIntentResult.hasErrors() && + badIntentResult.errors.any { it.contains("intent") } + + // Invalid thumbnail format + val badThumbnail = """ + { + "version": 1, + "builder": { + "thumbnail": { + "format": "bmp", + "quality": "ultra" + } + } + } + """.trimIndent() + val badThumbResult = SettingsValidator.validate(badThumbnail, logWarnings = false) + val badFormatDetected = badThumbResult.hasErrors() && + badThumbResult.errors.any { it.contains("format") } + val badQualityDetected = badThumbResult.hasErrors() && + badThumbResult.errors.any { it.contains("quality") } + + // created_assertion_labels not an array + val badLabels = """{"version": 1, "builder": {"created_assertion_labels": "not_array"}}""" + val badLabelsResult = SettingsValidator.validate(badLabels, logWarnings = false) + val badLabelsDetected = badLabelsResult.hasErrors() && + badLabelsResult.errors.any { it.contains("created_assertion_labels") } + + val success = intentValid && badIntentDetected && badFormatDetected && + badQualityDetected && badLabelsDetected + + TestResult( + "Settings Validator - Builder Section", + success, + if (success) { + "Builder section validation works" + } else { + "Builder section validation failed" + }, + "Intent valid: $intentValid, Bad intent: $badIntentDetected, " + + "Bad format: $badFormatDetected, Bad quality: $badQualityDetected, " + + "Bad labels: $badLabelsDetected", + ) + } catch (e: Exception) { + TestResult( + "Settings Validator - Builder Section", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testSettingsValidatorSignerSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Settings Validator - Signer Section") { + try { + // Signer with neither local nor remote + val noSigner = """{"version": 1, "signer": {}}""" + val noSignerResult = SettingsValidator.validate(noSigner, logWarnings = false) + val noSignerDetected = noSignerResult.hasErrors() && + noSignerResult.errors.any { it.contains("local") || it.contains("remote") } + + // Local signer missing required fields + val badLocal = """{"version": 1, "signer": {"local": {}}}""" + val badLocalResult = SettingsValidator.validate(badLocal, logWarnings = false) + val missingAlg = badLocalResult.errors.any { it.contains("alg") } + val missingCert = badLocalResult.errors.any { it.contains("sign_cert") } + val missingKey = badLocalResult.errors.any { it.contains("private_key") } + + // Remote signer missing required fields + val badRemote = """{"version": 1, "signer": {"remote": {}}}""" + val badRemoteResult = SettingsValidator.validate(badRemote, logWarnings = false) + val missingUrl = badRemoteResult.errors.any { it.contains("url") } + + // Both local and remote + val bothSigners = """{"version": 1, "signer": {"local": {}, "remote": {}}}""" + val bothResult = SettingsValidator.validate(bothSigners, logWarnings = false) + val bothDetected = bothResult.errors.any { it.contains("both") } + + val success = noSignerDetected && missingAlg && missingCert && missingKey && + missingUrl && bothDetected + + TestResult( + "Settings Validator - Signer Section", + success, + if (success) { + "Signer section validation works" + } else { + "Signer section validation failed" + }, + "No signer: $noSignerDetected, Missing alg: $missingAlg, " + + "Missing cert: $missingCert, Missing key: $missingKey, " + + "Missing URL: $missingUrl, Both: $bothDetected", + ) + } catch (e: Exception) { + TestResult( + "Settings Validator - Signer Section", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + /** + * Tests ManifestValidator for deprecated assertion detection. + */ + suspend fun testManifestValidatorDeprecatedAssertions(): TestResult = withContext(Dispatchers.IO) { + runTest("Manifest Validator - Deprecated Assertions") { + try { + val manifest = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.exif( + mapOf("Make" to JsonPrimitive("TestCamera")), + ), + ), + ) + + val result = ManifestValidator.validate(manifest) + val hasDeprecatedWarning = result.hasWarnings() && + result.warnings.any { it.contains("stds.exif") && it.contains("deprecated") } + + TestResult( + "Manifest Validator - Deprecated Assertions", + hasDeprecatedWarning, + if (hasDeprecatedWarning) { + "Deprecated assertion validation works" + } else { + "Deprecated assertion validation failed" + }, + "Warnings: ${result.warnings}", + ) + } catch (e: Exception) { + TestResult( + "Manifest Validator - Deprecated Assertions", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + /** + * Helper function to clone and compare a manifest via JSON. + */ + suspend fun testDigitalSourceTypeFromIptcUrl(): TestResult = withContext(Dispatchers.IO) { + runTest("DigitalSourceType fromIptcUrl") { + try { + var allMatch = true + val mismatches = mutableListOf() + + DigitalSourceType.entries.forEach { sourceType -> + val url = sourceType.toIptcUrl() + val parsed = DigitalSourceType.fromIptcUrl(url) + if (parsed != sourceType) { + allMatch = false + mismatches.add("$sourceType: toIptcUrl='$url', fromIptcUrl=$parsed") + } + } + + // Also test unknown URL returns null + val unknown = DigitalSourceType.fromIptcUrl("http://example.com/unknown") + val unknownIsNull = unknown == null + + val success = allMatch && unknownIsNull + + TestResult( + "DigitalSourceType fromIptcUrl", + success, + if (success) { + "All ${DigitalSourceType.entries.size} source types round-trip correctly" + } else { + "Round-trip failed" + }, + if (mismatches.isNotEmpty()) { + "Mismatches: $mismatches" + } else { + "All entries match, unknown URL returns null: $unknownIsNull" + }, + ) + } catch (e: Exception) { + TestResult( + "DigitalSourceType fromIptcUrl", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testManifestAssertionLabels(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestDefinition assertionLabels") { + try { + val manifest = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion(action = PredefinedAction.CREATED)), + ), + AssertionDefinition.actions( + listOf(ActionAssertion(action = PredefinedAction.EDITED)), + ), + AssertionDefinition.custom("com.example.test", buildJsonObject { put("key", "value") }), + ), + ) + + val labels = manifest.assertionLabels() + val hasActions = labels.contains("c2pa.actions") + val hasCustom = labels.contains("com.example.test") + // Two Actions assertions should deduplicate to one label + val isDistinct = labels.size == 2 + + val success = hasActions && hasCustom && isDistinct + + TestResult( + "ManifestDefinition assertionLabels", + success, + if (success) { + "assertionLabels returns distinct base labels" + } else { + "assertionLabels failed" + }, + "Labels: $labels (expected 2 distinct)", + ) + } catch (e: Exception) { + TestResult( + "ManifestDefinition assertionLabels", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testManifestToPrettyJson(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestDefinition toPrettyJson") { + try { + val manifest = ManifestDefinition( + title = "Pretty Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion(action = PredefinedAction.CREATED)), + ), + ), + ) + + val compact = manifest.toJson() + val pretty = manifest.toPrettyJson() + + // Pretty JSON should be longer (has indentation/newlines) + val isLonger = pretty.length > compact.length + // Pretty JSON should have newlines + val hasNewlines = pretty.contains("\n") + // Both should parse to equivalent manifests + val compactParsed = ManifestDefinition.fromJson(compact) + val prettyParsed = ManifestDefinition.fromJson(pretty) + val equivalent = compactParsed == prettyParsed + + val success = isLonger && hasNewlines && equivalent + + TestResult( + "ManifestDefinition toPrettyJson", + success, + if (success) { + "toPrettyJson produces formatted, parseable output" + } else { + "toPrettyJson failed" + }, + "Compact: ${compact.length} chars, Pretty: ${pretty.length} chars, " + + "Has newlines: $hasNewlines, Equivalent: $equivalent", + ) + } catch (e: Exception) { + TestResult( + "ManifestDefinition toPrettyJson", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testIptcPhotoMetadata(): TestResult = withContext(Dispatchers.IO) { + runTest("AssertionDefinition IptcPhotoMetadata") { + try { + val iptcData = mapOf( + "dc:creator" to JsonPrimitive("Test Author"), + "dc:description" to JsonPrimitive("A test image"), + "Iptc4xmpCore:Location" to JsonPrimitive("Test City"), + ) + + val manifest = ManifestDefinition( + title = "IPTC Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.IptcPhotoMetadata(data = iptcData), + ), + ) + + val jsonString = manifest.toJson() + val parsed = ManifestDefinition.fromJson(jsonString) + + val iptcAssertion = parsed.assertions.firstOrNull() + val isIptc = iptcAssertion is AssertionDefinition.IptcPhotoMetadata + val hasCreator = isIptc && jsonString.contains("Test Author") + val hasDescription = isIptc && jsonString.contains("A test image") + val hasLabel = jsonString.contains("stds.iptc.photo-metadata") + + val success = isIptc && hasCreator && hasDescription && hasLabel + + TestResult( + "AssertionDefinition IptcPhotoMetadata", + success, + if (success) { + "IptcPhotoMetadata serializes and deserializes" + } else { + "IptcPhotoMetadata round-trip failed" + }, + "Is IPTC: $isIptc, Has creator: $hasCreator, Has description: $hasDescription, " + + "Has label: $hasLabel", + ) + } catch (e: Exception) { + TestResult( + "AssertionDefinition IptcPhotoMetadata", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + /** + * Tests ManifestValidator custom assertion label validation and typo detection. + */ + suspend fun testCustomAssertionLabelValidation(): TestResult = withContext(Dispatchers.IO) { + runTest("Custom Assertion Label Validation") { + try { + val errors = mutableListOf() + + // Custom assertion with valid namespace should NOT warn about format + val validNamespace = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.custom( + "com.example.test", + buildJsonObject { put("key", "value") }, + ), + ), + ) + val validResult = ManifestValidator.validate(validNamespace) + if (validResult.warnings.any { it.contains("namespaced format") }) { + errors.add("Namespaced label 'com.example.test' should not trigger format warning") + } + + // Custom assertion WITHOUT namespace should warn + val noNamespace = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.custom( + "test", + buildJsonObject { put("key", "value") }, + ), + ), + ) + val noNsResult = ManifestValidator.validate(noNamespace) + if (!noNsResult.warnings.any { it.contains("namespaced format") }) { + errors.add("Non-namespaced label 'test' should trigger format warning") + } + + // Typo: c2pa.action -> c2pa.actions + val typo1 = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.custom( + "c2pa.action", + buildJsonObject { put("key", "value") }, + ), + ), + ) + val typo1Result = ManifestValidator.validate(typo1) + if (!typo1Result.warnings.any { it.contains("c2pa.actions") && it.contains("typo") }) { + errors.add("Typo 'c2pa.action' should suggest 'c2pa.actions'") + } + + // Typo: stds.iptc -> stds.iptc.photo-metadata + val typo2 = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.custom( + "stds.iptc", + buildJsonObject { put("key", "value") }, + ), + ), + ) + val typo2Result = ManifestValidator.validate(typo2) + if (!typo2Result.warnings.any { it.contains("stds.iptc.photo-metadata") }) { + errors.add("Typo 'stds.iptc' should suggest 'stds.iptc.photo-metadata'") + } + + // Typo: cawg.training -> cawg.training-mining + val typo3 = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.custom( + "cawg.training", + buildJsonObject { put("key", "value") }, + ), + ), + ) + val typo3Result = ManifestValidator.validate(typo3) + if (!typo3Result.warnings.any { it.contains("cawg.training-mining") }) { + errors.add("Typo 'cawg.training' should suggest 'cawg.training-mining'") + } + + // Standard assertion type (Actions) should NOT trigger custom label warnings + val standard = ManifestDefinition( + title = "Test", + claimGeneratorInfo = listOf(ClaimGeneratorInfo(name = "test")), + assertions = listOf( + AssertionDefinition.actions( + listOf(ActionAssertion.created(DigitalSourceType.DIGITAL_CAPTURE)), + ), + ), + ) + val standardResult = ManifestValidator.validate(standard) + if (standardResult.warnings.any { it.contains("namespaced") || it.contains("typo") }) { + errors.add("Standard Actions assertion should not trigger label warnings") + } + + // Exercise validateAndLog() path + val logResult = ManifestValidator.validateAndLog(validNamespace) + if (logResult.hasErrors()) { + errors.add("validateAndLog returned unexpected errors: ${logResult.errors}") + } + + val success = errors.isEmpty() + TestResult( + "Custom Assertion Label Validation", + success, + if (success) "All custom label validation cases pass" else "Custom label validation failures", + errors.joinToString("\n"), + ) + } catch (e: Exception) { + TestResult( + "Custom Assertion Label Validation", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests ImageRegionType.toTypeString() for all 16 enum values. + */ + suspend fun testImageRegionTypeToTypeString(): TestResult = withContext(Dispatchers.IO) { + runTest("ImageRegionType toTypeString") { + try { + val errors = mutableListOf() + val prefix = "http://cv.iptc.org/newscodes/imageregiontype/" + + ImageRegionType.entries.forEach { type -> + val url = type.toTypeString() + if (!url.startsWith(prefix)) { + errors.add("${type.name}: '$url' does not start with IPTC prefix") + } + } + + // Spot-check specific mappings + val spotChecks = mapOf( + ImageRegionType.HUMAN to "${prefix}human", + ImageRegionType.FACE to "${prefix}face", + ImageRegionType.BODY_PART to "${prefix}bodyPart", + ImageRegionType.VISIBLE_CODE to "${prefix}visibleCode", + ImageRegionType.GEO_FEATURE to "${prefix}geoFeature", + ) + spotChecks.forEach { (type, expected) -> + val actual = type.toTypeString() + if (actual != expected) { + errors.add("${type.name}: expected '$expected', got '$actual'") + } + } + + val success = errors.isEmpty() + TestResult( + "ImageRegionType toTypeString", + success, + if (success) { + "All ${ImageRegionType.entries.size} toTypeString values are correct" + } else { + "toTypeString failures" + }, + errors.joinToString("\n"), + ) + } catch (e: Exception) { + TestResult( + "ImageRegionType toTypeString", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + + /** + * Tests StandardAssertionLabel.serialName() for all enum values. + */ + suspend fun testStandardAssertionLabelSerialNames(): TestResult = withContext(Dispatchers.IO) { + runTest("StandardAssertionLabel serialNames") { + try { + val errors = mutableListOf() + + // Every serialName() should be non-empty + StandardAssertionLabel.entries.forEach { label -> + val name = label.serialName() + if (name.isBlank()) { + errors.add("${label.name}: serialName() is blank") + } + } + + // Spot-check critical values + val spotChecks = mapOf( + StandardAssertionLabel.ACTIONS to "c2pa.actions", + StandardAssertionLabel.ACTIONS_V2 to "c2pa.actions.v2", + StandardAssertionLabel.EXIF to "stds.exif", + StandardAssertionLabel.CREATIVE_WORK to "stds.schema-org.CreativeWork", + StandardAssertionLabel.ISO_LOCATION to "stds.iso.location.v1", + StandardAssertionLabel.CAWG_AI_TRAINING to "cawg.training-mining", + StandardAssertionLabel.FONT_INFO to "font.info", + StandardAssertionLabel.THUMBNAIL_CLAIM to "c2pa.thumbnail.claim", + StandardAssertionLabel.HASH_BMFF to "c2pa.hash.bmff", + StandardAssertionLabel.DEPTHMAP_GDEPTH to "c2pa.depthmap.GDepth", + StandardAssertionLabel.TRAINING_MINING to "c2pa.training-mining", + StandardAssertionLabel.CAWG_METADATA to "cawg.metadata", + ) + spotChecks.forEach { (label, expected) -> + val actual = label.serialName() + if (actual != expected) { + errors.add("${label.name}: expected '$expected', got '$actual'") + } + } + + val success = errors.isEmpty() + TestResult( + "StandardAssertionLabel serialNames", + success, + if (success) { + "All ${StandardAssertionLabel.entries.size} serialName values are correct" + } else { + "serialName failures" + }, + errors.joinToString("\n"), + ) + } catch (e: Exception) { + TestResult( + "StandardAssertionLabel serialNames", + false, + "Error: ${e.message}", + e.stackTraceToString(), + ) + } + } + } + private fun cloneAndCompare(manifest: ManifestDefinition, testName: String): TestResult { return try { val jsonString = manifest.toJson()