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/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/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/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/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/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 e11f7fc..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. @@ -115,12 +116,39 @@ class Builder internal constructor(private var ptr: Long) : Closeable { loadC2PALibraries() } + /** + * Default assertion labels that are attributed to the signer (created assertions). + * + * 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. * - * 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 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)]. * * @param manifestJSON The manifest definition as a JSON string * @return A Builder instance configured with the provided manifest @@ -142,12 +170,37 @@ 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 { + val validation = ManifestValidator.validateJson(manifestJSON, logWarnings = true) + if (validation.hasErrors()) { + throw C2PAError.Api(validation.errors.joinToString("; ")) + } + + 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 +221,124 @@ 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 { + val validation = ManifestValidator.validateJson(manifestJSON, logWarnings = true) + if (validation.hasErrors()) { + throw C2PAError.Api(validation.errors.joinToString("; ")) + } + + 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 +349,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 +379,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 +479,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 +502,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 +525,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 +565,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/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/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/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/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/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/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/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 c200f92..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 @@ -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 @@ -228,6 +241,32 @@ 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) + 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..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,12 +14,15 @@ 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 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 +42,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 +92,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 +108,7 @@ abstract class BuilderTests : TestBase() { }, "Archive size: ${data.size}", ) - } finally { - archiveStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -135,34 +124,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 +177,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 +230,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 +289,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 +325,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 +343,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 +393,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 +409,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 +444,6 @@ abstract class BuilderTests : TestBase() { "Failed to read manifest", e.toString(), ) - } finally { - memStream.close() } } } @@ -468,8 +453,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 +463,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 +908,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( @@ -553,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")), ), ) @@ -564,42 +937,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/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() 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). */