From 22a7f35f3a49958a62b3173fd7e6ce957628e2da Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Tue, 24 Feb 2026 19:34:36 +0100 Subject: [PATCH] Add context, settings, and JSON APIs Introduce C2PAContext and C2PASettings for shared configuration across readers and builders. Add C2PAJson for centralized JSON serialization. Add SettingsValidator for validating C2PA settings. Rewrite Builder to use context-based creation flow. Add Reader context support with withStream and withFragment. Update native library to v0.75.19. --- library/build.gradle.kts | 2 +- library/gradle.properties | 2 +- .../contentauth/c2pa/AndroidBuilderTests.kt | 51 +- .../c2pa/AndroidSettingsValidatorTests.kt | 148 +++ .../contentauth/c2pa/AndroidStreamTests.kt | 15 + library/src/main/jni/c2pa_jni.c | 264 +++- .../kotlin/org/contentauth/c2pa/Builder.kt | 296 ++++- .../org/contentauth/c2pa/C2PAContext.kt | 105 ++ .../kotlin/org/contentauth/c2pa/C2PAJson.kt | 42 + .../org/contentauth/c2pa/C2PASettings.kt | 109 ++ .../contentauth/c2pa/CertificateManager.kt | 19 +- .../kotlin/org/contentauth/c2pa/Reader.kt | 91 ++ .../kotlin/org/contentauth/c2pa/Stream.kt | 130 +- .../org/contentauth/c2pa/WebServiceSigner.kt | 15 +- .../c2pa/manifest/SettingsValidator.kt | 725 +++++++++++ .../c2pa/manifest/ValidationResult.kt | 37 + .../contentauth/c2pa/testapp/TestScreen.kt | 36 +- .../c2pa/test/shared/BuilderTests.kt | 896 ++++++++++---- .../test/shared/SettingsValidatorTests.kt | 1081 +++++++++++++++++ .../c2pa/test/shared/StreamTests.kt | 145 +++ .../contentauth/c2pa/test/shared/TestBase.kt | 27 +- 21 files changed, 3851 insertions(+), 385 deletions(-) create mode 100644 library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/SettingsValidator.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt create mode 100644 test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 0d246a3..46691df 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -36,10 +36,10 @@ android { // Specify ABIs to use prebuilt .so files ndk { - abiFilters.add("x86_64") abiFilters.add("arm64-v8a") abiFilters.add("armeabi-v7a") abiFilters.add("x86") + abiFilters.add("x86_64") } } diff --git a/library/gradle.properties b/library/gradle.properties index d2535b9..0fe5ca1 100644 --- a/library/gradle.properties +++ b/library/gradle.properties @@ -1,3 +1,3 @@ # C2PA Native Library Version # Update this to use a different release from https://github.com/contentauth/c2pa-rs/releases -c2paVersion=v0.75.8 +c2paVersion=v0.75.19 diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt index bcf8564..2f15dd9 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -9,6 +9,7 @@ ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE files for the specific language governing permissions and limitations under each license. */ + package org.contentauth.c2pa import android.content.Context @@ -85,4 +86,52 @@ class AndroidBuilderTests : BuilderTests() { val result = testJsonRoundTrip() assertTrue(result.success, "JSON Round-trip test failed: ${result.message}") } + + @Test + fun runTestBuilderFromContextWithSettings() = runBlocking { + val result = testBuilderFromContextWithSettings() + assertTrue(result.success, "Builder from Context with Settings test failed: ${result.message}") + } + + @Test + fun runTestBuilderFromJsonWithSettings() = runBlocking { + val result = testBuilderFromJsonWithSettings() + assertTrue(result.success, "Builder fromJson with Settings test failed: ${result.message}") + } + + @Test + fun runTestBuilderWithArchive() = runBlocking { + val result = testBuilderWithArchive() + assertTrue(result.success, "Builder withArchive test failed: ${result.message}") + } + + @Test + fun runTestReaderFromContext() = runBlocking { + val result = testReaderFromContext() + assertTrue(result.success, "Reader fromContext test failed: ${result.message}") + } + + @Test + fun runTestBuilderSetIntent() = runBlocking { + val result = testBuilderSetIntent() + assertTrue(result.success, "Builder Set Intent test failed: ${result.message}") + } + + @Test + fun runTestBuilderAddAction() = runBlocking { + val result = testBuilderAddAction() + assertTrue(result.success, "Builder Add Action test failed: ${result.message}") + } + + @Test + fun runTestSettingsSetValue() = runBlocking { + val result = testSettingsSetValue() + assertTrue(result.success, "C2PASettings setValue test failed: ${result.message}") + } + + @Test + fun runTestBuilderIntentEditAndUpdate() = runBlocking { + val result = testBuilderIntentEditAndUpdate() + assertTrue(result.success, "Builder Intent Edit and Update test failed: ${result.message}") + } } diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt new file mode 100644 index 0000000..548ea6c --- /dev/null +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsValidatorTests.kt @@ -0,0 +1,148 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ +package org.contentauth.c2pa + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.contentauth.c2pa.test.shared.SettingsValidatorTests +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.test.assertTrue + +/** Android instrumented tests for SettingsValidator. */ +@RunWith(AndroidJUnit4::class) +class AndroidSettingsValidatorTests : SettingsValidatorTests() { + + private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + override fun getContext(): Context = targetContext + + override fun loadResourceAsBytes(resourceName: String): ByteArray = + ResourceTestHelper.loadResourceAsBytes(resourceName) + + override fun loadResourceAsString(resourceName: String): String = + ResourceTestHelper.loadResourceAsString(resourceName) + + override fun copyResourceToFile(resourceName: String, fileName: String): File = + ResourceTestHelper.copyResourceToFile(targetContext, resourceName, fileName) + + @Test + fun runTestValidSettings() = runBlocking { + val result = testValidSettings() + assertTrue(result.success, "Valid Settings test failed: ${result.message}") + } + + @Test + fun runTestInvalidJson() = runBlocking { + val result = testInvalidJson() + assertTrue(result.success, "Invalid JSON test failed: ${result.message}") + } + + @Test + fun runTestMissingVersion() = runBlocking { + val result = testMissingVersion() + assertTrue(result.success, "Missing Version test failed: ${result.message}") + } + + @Test + fun runTestWrongVersion() = runBlocking { + val result = testWrongVersion() + assertTrue(result.success, "Wrong Version test failed: ${result.message}") + } + + @Test + fun runTestUnknownTopLevelKeys() = runBlocking { + val result = testUnknownTopLevelKeys() + assertTrue(result.success, "Unknown Top-Level Keys test failed: ${result.message}") + } + + @Test + fun runTestTrustSection() = runBlocking { + val result = testTrustSection() + assertTrue(result.success, "Trust Section test failed: ${result.message}") + } + + @Test + fun runTestCawgTrustSection() = runBlocking { + val result = testCawgTrustSection() + assertTrue(result.success, "CAWG Trust Section test failed: ${result.message}") + } + + @Test + fun runTestCoreSection() = runBlocking { + val result = testCoreSection() + assertTrue(result.success, "Core Section test failed: ${result.message}") + } + + @Test + fun runTestVerifySection() = runBlocking { + val result = testVerifySection() + assertTrue(result.success, "Verify Section test failed: ${result.message}") + } + + @Test + fun runTestBuilderSection() = runBlocking { + val result = testBuilderSection() + assertTrue(result.success, "Builder Section test failed: ${result.message}") + } + + @Test + fun runTestThumbnailSection() = runBlocking { + val result = testThumbnailSection() + assertTrue(result.success, "Thumbnail Section test failed: ${result.message}") + } + + @Test + fun runTestActionsSection() = runBlocking { + val result = testActionsSection() + assertTrue(result.success, "Actions Section test failed: ${result.message}") + } + + @Test + fun runTestLocalSigner() = runBlocking { + val result = testLocalSigner() + assertTrue(result.success, "Local Signer test failed: ${result.message}") + } + + @Test + fun runTestRemoteSigner() = runBlocking { + val result = testRemoteSigner() + assertTrue(result.success, "Remote Signer test failed: ${result.message}") + } + + @Test + fun runTestSignerMutualExclusion() = runBlocking { + val result = testSignerMutualExclusion() + assertTrue(result.success, "Signer Mutual Exclusion test failed: ${result.message}") + } + + @Test + fun runTestValidationResultHelpers() = runBlocking { + val result = testValidationResultHelpers() + assertTrue(result.success, "ValidationResult Helpers test failed: ${result.message}") + } + + @Test + fun runTestValidateAndLog() = runBlocking { + val result = testValidateAndLog() + assertTrue(result.success, "Validate and Log test failed: ${result.message}") + } + + @Test + fun runTestIntentAsNumber() = runBlocking { + val result = testIntentAsNumber() + assertTrue(result.success, "Intent As Number test failed: ${result.message}") + } +} diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt index e861636..001043e 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt @@ -62,6 +62,21 @@ class AndroidStreamTests : StreamTests() { assertTrue(result.success, "Custom Stream Callbacks test failed: ${result.message}") } + @Test + fun runTestCallbackStreamFactories() = runBlocking { + val result = testCallbackStreamFactories() + assertTrue(result.success, "Callback Stream Factories test failed: ${result.message}") + } + + @Test + fun runTestByteArrayStreamBufferGrowth() = runBlocking { + val result = testByteArrayStreamBufferGrowth() + assertTrue( + result.success, + "ByteArrayStream Buffer Growth test failed: ${result.message}", + ) + } + @Test fun runTestFileOperationsWithDataDirectory() = runBlocking { val result = testFileOperationsWithDataDirectory() diff --git a/library/src/main/jni/c2pa_jni.c b/library/src/main/jni/c2pa_jni.c index 1bb3252..fbe6a76 100644 --- a/library/src/main/jni/c2pa_jni.c +++ b/library/src/main/jni/c2pa_jni.c @@ -750,29 +750,6 @@ JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_resourceToStreamNative( } // Builder native methods -JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromJson(JNIEnv *env, jclass clazz, jstring manifestJson) { - if (manifestJson == NULL) { - (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), - "Manifest JSON cannot be null"); - return 0; - } - - const char *cmanifestJson = jstring_to_cstring(env, manifestJson); - if (cmanifestJson == NULL) { - return 0; - } - - struct C2paBuilder *builder = c2pa_builder_from_json(cmanifestJson); - release_cstring(env, manifestJson, cmanifestJson); - - if (builder == NULL) { - throw_c2pa_exception(env, "Failed to create builder from JSON"); - return 0; - } - - return (jlong)(uintptr_t)builder; -} - JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromArchive(JNIEnv *env, jclass clazz, jlong streamPtr) { if (streamPtr == 0) { (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), @@ -1216,6 +1193,247 @@ JNIEXPORT void JNICALL Java_org_contentauth_c2pa_Signer_free(JNIEnv *env, jobjec } } +// C2PASettings native methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PASettings_nativeNew(JNIEnv *env, jclass clazz) { + struct C2paSettings *settings = c2pa_settings_new(); + if (settings == NULL) { + return 0; + } + return (jlong)(uintptr_t)settings; +} + +JNIEXPORT jint JNICALL Java_org_contentauth_c2pa_C2PASettings_updateFromStringNative(JNIEnv *env, jobject obj, jlong settingsPtr, jstring settingsStr, jstring format) { + if (settingsPtr == 0 || settingsStr == NULL || format == NULL) { + return -1; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + const char *csettingsStr = jstring_to_cstring(env, settingsStr); + const char *cformat = jstring_to_cstring(env, format); + + if (csettingsStr == NULL || cformat == NULL) { + release_cstring(env, settingsStr, csettingsStr); + release_cstring(env, format, cformat); + return -1; + } + + int result = c2pa_settings_update_from_string(settings, csettingsStr, cformat); + + release_cstring(env, settingsStr, csettingsStr); + release_cstring(env, format, cformat); + + return result; +} + +JNIEXPORT jint JNICALL Java_org_contentauth_c2pa_C2PASettings_setValueNative(JNIEnv *env, jobject obj, jlong settingsPtr, jstring path, jstring value) { + if (settingsPtr == 0 || path == NULL || value == NULL) { + return -1; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + const char *cpath = jstring_to_cstring(env, path); + const char *cvalue = jstring_to_cstring(env, value); + + if (cpath == NULL || cvalue == NULL) { + release_cstring(env, path, cpath); + release_cstring(env, value, cvalue); + return -1; + } + + int result = c2pa_settings_set_value(settings, cpath, cvalue); + + release_cstring(env, path, cpath); + release_cstring(env, value, cvalue); + + return result; +} + +JNIEXPORT void JNICALL Java_org_contentauth_c2pa_C2PASettings_free(JNIEnv *env, jobject obj, jlong settingsPtr) { + if (settingsPtr != 0) { + c2pa_free((const void*)(uintptr_t)settingsPtr); + } +} + +// C2PAContext native methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PAContext_nativeNew(JNIEnv *env, jclass clazz) { + struct C2paContext *context = c2pa_context_new(); + if (context == NULL) { + return 0; + } + return (jlong)(uintptr_t)context; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PAContext_nativeNewWithSettings(JNIEnv *env, jclass clazz, jlong settingsPtr) { + if (settingsPtr == 0) { + return 0; + } + + struct C2paContextBuilder *builder = c2pa_context_builder_new(); + if (builder == NULL) { + return 0; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + int result = c2pa_context_builder_set_settings(builder, settings); + if (result < 0) { + c2pa_free(builder); + return 0; + } + + // build consumes the builder + struct C2paContext *context = c2pa_context_builder_build(builder); + if (context == NULL) { + return 0; + } + + return (jlong)(uintptr_t)context; +} + +JNIEXPORT void JNICALL Java_org_contentauth_c2pa_C2PAContext_free(JNIEnv *env, jobject obj, jlong contextPtr) { + if (contextPtr != 0) { + c2pa_free((const void*)(uintptr_t)contextPtr); + } +} + +// Builder context-based methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromContext(JNIEnv *env, jclass clazz, jlong contextPtr) { + if (contextPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Context cannot be null"); + return 0; + } + + struct C2paContext *context = (struct C2paContext*)(uintptr_t)contextPtr; + struct C2paBuilder *builder = c2pa_builder_from_context(context); + + if (builder == NULL) { + throw_c2pa_exception(env, "Failed to create builder from context"); + return 0; + } + + return (jlong)(uintptr_t)builder; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_withDefinitionNative(JNIEnv *env, jobject obj, jlong builderPtr, jstring manifestJson) { + if (builderPtr == 0 || manifestJson == NULL) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Builder and manifest JSON cannot be null"); + return 0; + } + + struct C2paBuilder *builder = (struct C2paBuilder*)(uintptr_t)builderPtr; + const char *cmanifestJson = jstring_to_cstring(env, manifestJson); + if (cmanifestJson == NULL) { + return 0; + } + + // This consumes the old builder pointer + struct C2paBuilder *newBuilder = c2pa_builder_with_definition(builder, cmanifestJson); + release_cstring(env, manifestJson, cmanifestJson); + + if (newBuilder == NULL) { + throw_c2pa_exception(env, "Failed to set builder definition"); + return 0; + } + + return (jlong)(uintptr_t)newBuilder; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_withArchiveNative(JNIEnv *env, jobject obj, jlong builderPtr, jlong streamPtr) { + if (builderPtr == 0 || streamPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Builder and stream cannot be null"); + return 0; + } + + struct C2paBuilder *builder = (struct C2paBuilder*)(uintptr_t)builderPtr; + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + + // This consumes the old builder pointer + struct C2paBuilder *newBuilder = c2pa_builder_with_archive(builder, stream); + + if (newBuilder == NULL) { + throw_c2pa_exception(env, "Failed to set builder archive"); + return 0; + } + + return (jlong)(uintptr_t)newBuilder; +} + +// Reader context-based methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_nativeFromContext(JNIEnv *env, jclass clazz, jlong contextPtr) { + if (contextPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Context cannot be null"); + return 0; + } + + struct C2paContext *context = (struct C2paContext*)(uintptr_t)contextPtr; + struct C2paReader *reader = c2pa_reader_from_context(context); + + if (reader == NULL) { + throw_c2pa_exception(env, "Failed to create reader from context"); + return 0; + } + + return (jlong)(uintptr_t)reader; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_withStreamNative(JNIEnv *env, jobject obj, jlong readerPtr, jstring format, jlong streamPtr) { + if (readerPtr == 0 || format == NULL || streamPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Reader, format, and stream cannot be null"); + return 0; + } + + struct C2paReader *reader = (struct C2paReader*)(uintptr_t)readerPtr; + const char *cformat = jstring_to_cstring(env, format); + if (cformat == NULL) { + return 0; + } + + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + + // This consumes the old reader pointer + struct C2paReader *newReader = c2pa_reader_with_stream(reader, cformat, stream); + release_cstring(env, format, cformat); + + if (newReader == NULL) { + throw_c2pa_exception(env, "Failed to configure reader with stream"); + return 0; + } + + return (jlong)(uintptr_t)newReader; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_withFragmentNative(JNIEnv *env, jobject obj, jlong readerPtr, jstring format, jlong streamPtr, jlong fragmentPtr) { + if (readerPtr == 0 || format == NULL || streamPtr == 0 || fragmentPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Reader, format, stream, and fragment cannot be null"); + return 0; + } + + struct C2paReader *reader = (struct C2paReader*)(uintptr_t)readerPtr; + const char *cformat = jstring_to_cstring(env, format); + if (cformat == NULL) { + return 0; + } + + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + struct C2paStream *fragment = (struct C2paStream*)(uintptr_t)fragmentPtr; + + // This consumes the old reader pointer + struct C2paReader *newReader = c2pa_reader_with_fragment(reader, cformat, stream, fragment); + release_cstring(env, format, cformat); + + if (newReader == NULL) { + throw_c2pa_exception(env, "Failed to configure reader with fragment"); + return 0; + } + + return (jlong)(uintptr_t)newReader; +} + // Ed25519 signing JNIEXPORT jbyteArray JNICALL Java_org_contentauth_c2pa_C2PA_ed25519SignNative(JNIEnv *env, jclass clazz, jbyteArray data, jstring privateKey) { if (data == NULL || privateKey == NULL) { diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt index e11f7fc..078d130 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt @@ -115,12 +115,29 @@ class Builder internal constructor(private var ptr: Long) : Closeable { loadC2PALibraries() } + /** + * Default assertion labels that should be placed in `created_assertions`. + * + * These are assertions that are typically generated by the signing application + * and should be attributed to the signer per the C2PA 2.3 specification. + * Override by passing a custom [C2PASettings] to [fromJson(String, C2PASettings)]. + */ + val DEFAULT_CREATED_ASSERTION_LABELS: List = listOf( + "c2pa.actions", + "c2pa.thumbnail.claim", + "c2pa.thumbnail.ingredient", + "c2pa.ingredient", + ) + /** * Creates a builder from a manifest definition in JSON format. * - * The JSON should contain the manifest structure including claims, assertions, and metadata - * according to the C2PA specification. This is useful for programmatically constructing - * manifests or loading manifest templates. + * This method automatically configures the SDK to place common assertions + * (actions, thumbnails, metadata) in `created_assertions` as intended by + * most applications. CAWG identity assertions are correctly placed in + * `gathered_assertions` per the CAWG specification. + * + * For full control over settings, use [fromJson(String, C2PASettings)]. * * @param manifestJSON The manifest definition as a JSON string * @return A Builder instance configured with the provided manifest @@ -142,12 +159,36 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * """ * val builder = Builder.fromJson(manifestJson) * ``` + * + * @see DEFAULT_CREATED_ASSERTION_LABELS + * @see fromJson(String, C2PASettings) */ @JvmStatic @Throws(C2PAError::class) - fun fromJson(manifestJSON: String): Builder = executeC2PAOperation("Failed to create builder from JSON") { - val handle = nativeFromJson(manifestJSON) - if (handle == 0L) null else Builder(handle) + fun fromJson(manifestJSON: String): Builder { + if (manifestJSON.isBlank()) { + throw C2PAError.Api("Manifest JSON must not be empty") + } + + val labelsArray = DEFAULT_CREATED_ASSERTION_LABELS.joinToString(", ") { "\"$it\"" } + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": [$labelsArray] + } + } + """.trimIndent() + + val settings = C2PASettings.create().apply { + updateFromString(settingsJson, "json") + } + val context = C2PAContext.fromSettings(settings) + settings.close() + + val builder = fromContext(context).withDefinition(manifestJSON) + context.close() + return builder } /** @@ -168,9 +209,123 @@ class Builder internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Builder(handle) } - @JvmStatic private external fun nativeFromJson(manifestJson: String): Long + /** + * Creates a builder from a shared [C2PAContext]. + * + * The context can be reused to create multiple builders and readers. + * The builder will inherit the context's settings. + * + * @param context The context to create the builder from + * @return A Builder instance configured with the context's settings + * @throws C2PAError.Api if the builder cannot be created + * + * @sample + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * val context = C2PAContext.fromSettings(settings) + * + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + * + * @see C2PAContext + * @see withDefinition + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromContext(context: C2PAContext): Builder = executeC2PAOperation("Failed to create builder from context") { + val handle = nativeFromContext(context.ptr) + if (handle == 0L) null else Builder(handle) + } + + /** + * Creates a builder from a manifest definition with custom settings. + * + * This gives full control over all SDK settings while also providing + * the manifest definition. The caller retains ownership of [settings] + * and may close it after this call returns. + * + * @param manifestJSON The manifest definition as a JSON string + * @param settings The settings to configure the builder with + * @return A Builder instance configured with the provided settings and manifest + * @throws C2PAError.Api if the JSON is invalid or settings cannot be applied + * + * @sample + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * val builder = Builder.fromJson(manifestJson, settings) + * settings.close() + * ``` + * + * @see C2PASettings + * @see fromJson + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromJson(manifestJSON: String, settings: C2PASettings): Builder { + if (manifestJSON.isBlank()) { + throw C2PAError.Api("Manifest JSON must not be empty") + } + + val context = C2PAContext.fromSettings(settings) + val builder = fromContext(context).withDefinition(manifestJSON) + context.close() + return builder + } @JvmStatic private external fun nativeFromArchive(streamHandle: Long): Long + + @JvmStatic private external fun nativeFromContext(contextPtr: Long): Long + } + + /** + * Updates the builder with a new manifest definition. + * + * @param manifestJSON The manifest definition as a JSON string + * @return This builder for fluent chaining + * @throws C2PAError.Api if the manifest JSON is invalid + * + * @sample + * ```kotlin + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + */ + @Throws(C2PAError::class) + fun withDefinition(manifestJSON: String): Builder { + val newPtr = withDefinitionNative(ptr, manifestJSON) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to set builder definition") + } + ptr = newPtr + return this + } + + /** + * Configures the builder with an archive stream. + * + * @param archive The input stream containing the C2PA archive + * @return This builder for fluent chaining + * @throws C2PAError.Api if the archive is invalid + * + * @sample + * ```kotlin + * val builder = Builder.fromContext(context) + * .withArchive(archiveStream) + * ``` + */ + @Throws(C2PAError::class) + fun withArchive(archive: Stream): Builder { + val newPtr = withArchiveNative(ptr, archive.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to set builder archive") + } + ptr = newPtr + return this } /** @@ -181,29 +336,26 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * ingredients are required. * * @param intent The [BuilderIntent] specifying the type of manifest + * @return This builder for fluent chaining * @throws C2PAError.Api if the intent cannot be set * * @sample * ```kotlin * val builder = Builder.fromJson(manifestJson) - * builder.setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) - * ``` - * - * @sample - * ```kotlin - * val builder = Builder.fromJson(manifestJson) - * builder.setIntent(BuilderIntent.Edit) + * .setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.CREATED)) * ``` * * @see BuilderIntent * @see DigitalSourceType */ @Throws(C2PAError::class) - fun setIntent(intent: BuilderIntent) { + fun setIntent(intent: BuilderIntent): Builder { val result = setIntentNative(ptr, intent.toNativeIntent(), intent.toNativeDigitalSourceType()) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to set intent") } + return this } /** @@ -214,57 +366,98 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * history. * * @param action The [Action] to add to the manifest + * @return This builder for fluent chaining * @throws C2PAError.Api if the action cannot be added * * @sample * ```kotlin * val builder = Builder.fromJson(manifestJson) - * builder.addAction(Action(PredefinedAction.EDITED, DigitalSourceType.DIGITAL_CAPTURE)) - * builder.addAction(Action(PredefinedAction.CROPPED, DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.EDITED, DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.CROPPED, DigitalSourceType.DIGITAL_CAPTURE)) * ``` * * @see Action * @see PredefinedAction */ @Throws(C2PAError::class) - fun addAction(action: Action) { + fun addAction(action: Action): Builder { val result = addActionNative(ptr, action.toJson()) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add action") } + return this } - /** Set the no-embed flag */ - fun setNoEmbed() = setNoEmbedNative(ptr) + /** + * Sets the no-embed flag, preventing the manifest from being embedded in the asset. + * + * @return This builder for fluent chaining + */ + fun setNoEmbed(): Builder { + setNoEmbedNative(ptr) + return this + } - /** Set the remote URL */ + /** + * Sets a remote URL where the manifest will be hosted. + * + * @param url The remote URL for the manifest + * @return This builder for fluent chaining + * @throws C2PAError.Api if the remote URL cannot be set + */ @Throws(C2PAError::class) - fun setRemoteURL(url: String) { + fun setRemoteURL(url: String): Builder { val result = setRemoteUrlNative(ptr, url) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to set remote URL") } + return this } - /** Add a resource to the builder */ + /** + * Adds a resource to the builder. + * + * @param uri The URI identifying the resource + * @param stream The stream containing the resource data + * @return This builder for fluent chaining + * @throws C2PAError.Api if the resource cannot be added + */ @Throws(C2PAError::class) - fun addResource(uri: String, stream: Stream) { + fun addResource(uri: String, stream: Stream): Builder { val result = addResourceNative(ptr, uri, stream.rawPtr) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add resource") } + return this } - /** Add an ingredient from a stream */ + /** + * Adds an ingredient from a stream. + * + * @param ingredientJSON JSON describing the ingredient + * @param format The MIME type of the ingredient (e.g., "image/jpeg") + * @param source The stream containing the ingredient data + * @return This builder for fluent chaining + * @throws C2PAError.Api if the ingredient cannot be added + */ @Throws(C2PAError::class) - fun addIngredient(ingredientJSON: String, format: String, source: Stream) { + fun addIngredient(ingredientJSON: String, format: String, source: Stream): Builder { val result = addIngredientFromStreamNative(ptr, ingredientJSON, format, source.rawPtr) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add ingredient") } + return this } - /** Write the builder to an archive */ + /** + * Writes the builder state to an archive stream. + * + * Archives are portable representations of a manifest and its associated resources + * that can later be loaded with [fromArchive] or [withArchive]. + * + * @param dest The output stream to write the archive to + * @throws C2PAError.Api if the archive cannot be written + */ @Throws(C2PAError::class) fun toArchive(dest: Stream) { val result = toArchiveNative(ptr, dest.rawPtr) @@ -273,7 +466,20 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } } - /** Sign and write the manifest */ + /** + * Signs the manifest and writes the signed asset to the destination stream. + * + * This is the primary method for producing a signed C2PA asset. The source stream + * provides the original asset data, and the signed output (with embedded manifest) + * is written to the destination stream. + * + * @param format The MIME type of the asset (e.g., "image/jpeg", "image/png") + * @param source The input stream containing the original asset + * @param dest The output stream for the signed asset + * @param signer The [Signer] to use for signing + * @return A [SignResult] containing the manifest size and optional manifest bytes + * @throws C2PAError.Api if signing fails + */ @Throws(C2PAError::class) fun sign(format: String, source: Stream, dest: Stream, signer: Signer): SignResult { val result = signNative(ptr, format, source.rawPtr, dest.rawPtr, signer.ptr) @@ -283,7 +489,20 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** Create a hashed placeholder for later signing */ + /** + * Creates a data-hashed placeholder for deferred signing workflows. + * + * This generates a placeholder manifest that can be embedded in an asset before + * the final signature is applied. Use [signDataHashedEmbeddable] to produce the + * final signed manifest after computing the asset's data hash. + * + * @param reservedSize The number of bytes to reserve for the manifest + * @param format The MIME type of the asset (e.g., "image/jpeg") + * @return The placeholder manifest as a byte array + * @throws C2PAError.Api if the placeholder cannot be created + * + * @see signDataHashedEmbeddable + */ @Throws(C2PAError::class) fun dataHashedPlaceholder(reservedSize: Long, format: String): ByteArray { val result = dataHashedPlaceholderNative(ptr, reservedSize, format) @@ -293,7 +512,22 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** Sign using data hash (advanced use) */ + /** + * Produces a signed manifest using a pre-computed data hash. + * + * This completes the deferred signing workflow started with [dataHashedPlaceholder]. + * The caller provides the hash of the asset data, and this method returns the final + * signed manifest bytes that can be embedded in the asset. + * + * @param signer The [Signer] to use for signing + * @param dataHash The hex-encoded hash of the asset data + * @param format The MIME type of the asset (e.g., "image/jpeg") + * @param asset Optional stream containing the asset (used for validation) + * @return The signed manifest as a byte array + * @throws C2PAError.Api if signing fails + * + * @see dataHashedPlaceholder + */ @Throws(C2PAError::class) fun signDataHashedEmbeddable(signer: Signer, dataHash: String, format: String, asset: Stream? = null): ByteArray { val result = @@ -318,6 +552,8 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } private external fun free(handle: Long) + private external fun withDefinitionNative(handle: Long, manifestJson: String): Long + private external fun withArchiveNative(handle: Long, streamHandle: Long): Long private external fun setIntentNative(handle: Long, intent: Int, digitalSourceType: Int): Int private external fun addActionNative(handle: Long, actionJson: String): Int private external fun setNoEmbedNative(handle: Long) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt new file mode 100644 index 0000000..e9ce847 --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt @@ -0,0 +1,105 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa + +import java.io.Closeable + +/** + * C2PA Context for creating readers and builders with shared configuration. + * + * C2PAContext wraps the native C2PAContext struct and provides an immutable, shareable + * configuration context. Once created, a context can be used to create multiple + * [Reader] and [Builder] instances that share the same settings. + * + * ## Usage + * + * ### Default context + * ```kotlin + * val context = C2PAContext.create() + * val builder = Builder.fromContext(context) + * val reader = Reader.fromContext(context) + * context.close() + * ``` + * + * ### Custom settings + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * + * val context = C2PAContext.fromSettings(settings) + * settings.close() + * + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + * + * ## Resource Management + * + * C2PAContext implements [Closeable] and must be closed when done to free native resources. + * The context can be closed after creating readers/builders from it. + * + * @property ptr Internal pointer to the native C2PAContext instance + * @see C2PASettings + * @see Builder + * @see Reader + * @since 1.0.0 + */ +class C2PAContext internal constructor(internal var ptr: Long) : Closeable { + + companion object { + init { + loadC2PALibraries() + } + + /** + * Creates a context with default settings. + * + * @return A new [C2PAContext] with default configuration + * @throws C2PAError.Api if the context cannot be created + */ + @JvmStatic + @Throws(C2PAError::class) + fun create(): C2PAContext = executeC2PAOperation("Failed to create C2PAContext") { + val handle = nativeNew() + if (handle == 0L) null else C2PAContext(handle) + } + + /** + * Creates a context with custom settings. + * + * The settings are cloned internally, so the caller retains ownership of [settings]. + * + * @param settings The settings to configure this context with + * @return A new [C2PAContext] configured with the provided settings + * @throws C2PAError.Api if the context cannot be created with the given settings + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromSettings(settings: C2PASettings): C2PAContext = executeC2PAOperation("Failed to create C2PAContext with settings") { + val handle = nativeNewWithSettings(settings.ptr) + if (handle == 0L) null else C2PAContext(handle) + } + + @JvmStatic private external fun nativeNew(): Long + @JvmStatic private external fun nativeNewWithSettings(settingsPtr: Long): Long + } + + override fun close() { + if (ptr != 0L) { + free(ptr) + ptr = 0 + } + } + + private external fun free(handle: Long) +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt new file mode 100644 index 0000000..288f9aa --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt @@ -0,0 +1,42 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa + +import kotlinx.serialization.json.Json + +/** + * Centralized JSON configuration for C2PA manifests and settings. + */ +object C2PAJson { + + /** + * Default JSON configuration for C2PA manifest serialization. + * + * Settings: + * - Does not encode default values (smaller output) + * - Ignores unknown keys (forward compatibility with newer C2PA versions) + */ + val default: Json = Json { + encodeDefaults = false + ignoreUnknownKeys = true + } + + /** + * Pretty-printed JSON configuration for debugging and display. + */ + val pretty: Json = Json { + encodeDefaults = false + ignoreUnknownKeys = true + prettyPrint = true + } +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt new file mode 100644 index 0000000..9956bef --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt @@ -0,0 +1,109 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa + +import java.io.Closeable + +/** + * C2PA Settings for configuring context-based operations. + * + * C2PASettings wraps the native C2PASettings struct and provides a fluent API for + * configuring settings that can be passed to [C2PAContext]. + * + * ## Usage + * + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString("""{"version": 1, "builder": {"created_assertion_labels": ["c2pa.actions"]}}""", "json") + * .setValue("verify.verify_after_sign", "true") + * + * val context = C2PAContext.fromSettings(settings) + * settings.close() // settings can be closed after creating the context + * ``` + * + * ## Resource Management + * + * C2PASettings implements [Closeable] and must be closed when done to free native resources. + * + * @property ptr Internal pointer to the native C2PASettings instance + * @see C2PAContext + * @since 1.0.0 + */ +class C2PASettings internal constructor(internal var ptr: Long) : Closeable { + + companion object { + init { + loadC2PALibraries() + } + + /** + * Creates a new settings instance with default values. + * + * @return A new [C2PASettings] instance + * @throws C2PAError.Api if the settings cannot be created + */ + @JvmStatic + @Throws(C2PAError::class) + fun create(): C2PASettings = executeC2PAOperation("Failed to create C2PASettings") { + val handle = nativeNew() + if (handle == 0L) null else C2PASettings(handle) + } + + @JvmStatic private external fun nativeNew(): Long + } + + /** + * Updates settings from a JSON or TOML string. + * + * @param settingsStr The settings string in JSON or TOML format + * @param format The format of the string ("json" or "toml") + * @return This settings instance for fluent chaining + * @throws C2PAError.Api if the settings string is invalid + */ + @Throws(C2PAError::class) + fun updateFromString(settingsStr: String, format: String): C2PASettings { + val result = updateFromStringNative(ptr, settingsStr, format) + if (result < 0) { + throw C2PAError.Api(C2PA.getError() ?: "Failed to update settings from string") + } + return this + } + + /** + * Sets a specific configuration value using dot notation. + * + * @param path Dot-separated path (e.g., "verify.verify_after_sign") + * @param value JSON value as a string (e.g., "true", "\"ps256\"", "42") + * @return This settings instance for fluent chaining + * @throws C2PAError.Api if the path or value is invalid + */ + @Throws(C2PAError::class) + fun setValue(path: String, value: String): C2PASettings { + val result = setValueNative(ptr, path, value) + if (result < 0) { + throw C2PAError.Api(C2PA.getError() ?: "Failed to set settings value") + } + return this + } + + override fun close() { + if (ptr != 0L) { + free(ptr) + ptr = 0 + } + } + + private external fun free(handle: Long) + private external fun updateFromStringNative(handle: Long, settingsStr: String, format: String): Int + private external fun setValueNative(handle: Long, path: String, value: String): Int +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt b/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt index 3aeb92b..eb7b110 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -21,7 +21,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.bouncycastle.asn1.ASN1ObjectIdentifier import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers import org.bouncycastle.asn1.x500.X500Name @@ -211,8 +210,7 @@ object CertificateManager { } } - // Private helper methods - + /** Builds an X.500 distinguished name from the certificate configuration. */ private fun buildX500Name(config: CertificateConfig): X500Name { val parts = mutableListOf() parts.add("CN=${config.commonName}") @@ -225,8 +223,8 @@ object CertificateManager { return X500Name(parts.joinToString(", ")) } + /** Creates a [ContentSigner] using the Android KeyStore for the given private key. */ private fun createContentSigner(privateKey: PrivateKey): ContentSigner { - // For EC keys, use SHA256withECDSA val signatureAlgorithm = when (privateKey.algorithm) { "EC" -> "SHA256withECDSA" @@ -241,6 +239,7 @@ object CertificateManager { return AndroidKeyStoreContentSigner(privateKey, signatureAlgorithm) } + /** Converts a PKCS#10 certification request to PEM-encoded string. */ private fun csrToPEM(csr: PKCS10CertificationRequest): String { val writer = StringWriter() val pemWriter = PemWriter(writer) @@ -250,6 +249,7 @@ object CertificateManager { return writer.toString() } + /** Creates a StrongBox-backed EC key pair in the Android KeyStore. */ private fun createStrongBoxKey( config: StrongBoxSigner.Config, tempCertConfig: TempCertificateConfig = @@ -424,11 +424,6 @@ object CertificateManager { val serial_number: String, ) - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - private suspend fun submitCSR( csr: String, metadata: CSRMetadata, @@ -448,7 +443,7 @@ object CertificateManager { apiKey?.let { connection.setRequestProperty("X-API-Key", it) } val request = CSRRequest(csr, metadata) - val requestJson = json.encodeToString(request) + val requestJson = C2PAJson.default.encodeToString(request) connection.outputStream.use { output -> output.write(requestJson.toByteArray()) @@ -458,7 +453,7 @@ object CertificateManager { val response = connection.inputStream.bufferedReader().use { it.readText() } connection.disconnect() - val csrResponse = json.decodeFromString(response) + val csrResponse = C2PAJson.default.decodeFromString(response) Result.success(csrResponse) } else { val error = diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt index cfa4bc5..9d0da47 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt @@ -102,6 +102,37 @@ class Reader internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Reader(handle) } + /** + * Creates a reader from a shared [C2PAContext]. + * + * The context can be reused to create multiple readers and builders. + * The reader will inherit the context's settings. Use [withStream] or + * [withFragment] to configure the reader with media data. + * + * @param context The context to create the reader from + * @return A Reader instance configured with the context's settings + * @throws C2PAError.Api if the reader cannot be created + * + * @sample + * ```kotlin + * val context = C2PAContext.create() + * val reader = Reader.fromContext(context) + * .withStream("image/jpeg", stream) + * val json = reader.json() + * ``` + * + * @see C2PAContext + * @see withStream + * @see withFragment + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromContext(context: C2PAContext): Reader = + executeC2PAOperation("Failed to create reader from context") { + val handle = nativeFromContext(context.ptr) + if (handle == 0L) null else Reader(handle) + } + /** * Creates a reader from manifest data and an associated media stream. * @@ -130,6 +161,8 @@ class Reader internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Reader(handle) } + @JvmStatic private external fun nativeFromContext(contextPtr: Long): Long + @JvmStatic private external fun fromStreamNative(format: String, streamHandle: Long): Long @JvmStatic @@ -140,6 +173,62 @@ class Reader internal constructor(private var ptr: Long) : Closeable { ): Long } + /** + * Configures the reader with a media stream. + * + * @param format The MIME type of the media (e.g., "image/jpeg", "video/mp4") + * @param stream The input stream containing the media file + * @return This reader for fluent chaining + * @throws C2PAError.Api if the stream cannot be read or the format is unsupported + * + * @sample + * ```kotlin + * val reader = Reader.fromContext(context) + * .withStream("image/jpeg", stream) + * val json = reader.json() + * ``` + */ + @Throws(C2PAError::class) + fun withStream(format: String, stream: Stream): Reader { + val newPtr = withStreamNative(ptr, format, stream.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to configure reader with stream") + } + ptr = newPtr + return this + } + + /** + * Configures the reader with a fragment stream for fragmented media. + * + * This is used for fragmented BMFF media formats where manifests are stored + * in separate fragments. + * + * @param format The MIME type of the media (e.g., "video/mp4") + * @param stream The main asset stream + * @param fragment The fragment stream + * @return This reader for fluent chaining + * @throws C2PAError.Api if the streams cannot be read or the format is unsupported + * + * @sample + * ```kotlin + * val reader = Reader.fromContext(context) + * .withFragment("video/mp4", mainStream, fragmentStream) + * val json = reader.json() + * ``` + */ + @Throws(C2PAError::class) + fun withFragment(format: String, stream: Stream, fragment: Stream): Reader { + val newPtr = withFragmentNative(ptr, format, stream.rawPtr, fragment.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to configure reader with fragment") + } + ptr = newPtr + return this + } + /** * Converts the C2PA manifest to a JSON string representation. * @@ -302,6 +391,8 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } private external fun free(handle: Long) + private external fun withStreamNative(handle: Long, format: String, streamHandle: Long): Long + private external fun withFragmentNative(handle: Long, format: String, streamHandle: Long, fragmentHandle: Long): Long private external fun toJsonNative(handle: Long): String? private external fun toDetailedJsonNative(handle: Long): String? private external fun remoteUrlNative(handle: Long): String? diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt b/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt index e6de3ed..912fc59 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -12,7 +12,6 @@ each license. package org.contentauth.c2pa -import java.io.ByteArrayOutputStream import java.io.Closeable import java.io.File import java.io.IOException @@ -37,6 +36,12 @@ typealias StreamFlusher = () -> Int /** Abstract base class for C2PA streams */ abstract class Stream : Closeable { + companion object { + init { + loadC2PALibraries() + } + } + private var nativeHandle: Long = 0 internal val rawPtr: Long get() = nativeHandle @@ -96,10 +101,17 @@ class DataStream(private val data: ByteArray) : Stream() { override fun write(data: ByteArray, length: Long): Long = throw UnsupportedOperationException("DataStream is read-only") - override fun flush(): Long = 0L + + override fun flush(): Long = + throw UnsupportedOperationException("DataStream is read-only") } -/** Stream implementation with callbacks */ +/** + * Stream implementation with callbacks. + * + * Consider using the type-safe factory methods [forReading], [forWriting], or [forReadWrite] + * to ensure required callbacks are provided at compile time. + */ class CallbackStream( private val reader: StreamReader? = null, private val seeker: StreamSeeker? = null, @@ -117,7 +129,7 @@ class CallbackStream( override fun seek(offset: Long, mode: Int): Long { val seekMode = - SeekMode.values().find { it.value == mode } + SeekMode.entries.find { it.value == mode } ?: throw IllegalArgumentException("Invalid seek mode: $mode") return seeker?.invoke(offset, seekMode) ?: throw UnsupportedOperationException( @@ -137,6 +149,55 @@ class CallbackStream( ?: throw UnsupportedOperationException( "Flush operation not supported: no flusher callback provided", ) + + companion object { + /** + * Creates a read-only callback stream. + * + * @param reader Callback to read data into a buffer, returning bytes read. + * @param seeker Callback to seek to a position, returning the new position. + * @return A CallbackStream configured for reading. + */ + fun forReading( + reader: StreamReader, + seeker: StreamSeeker, + ): CallbackStream = CallbackStream(reader = reader, seeker = seeker) + + /** + * Creates a write-only callback stream. + * + * @param writer Callback to write data from a buffer, returning bytes written. + * @param seeker Callback to seek to a position, returning the new position. + * @param flusher Callback to flush the stream, returning 0 on success. + * @return A CallbackStream configured for writing. + */ + fun forWriting( + writer: StreamWriter, + seeker: StreamSeeker, + flusher: StreamFlusher, + ): CallbackStream = CallbackStream(writer = writer, seeker = seeker, flusher = flusher) + + /** + * Creates a read-write callback stream. + * + * @param reader Callback to read data into a buffer, returning bytes read. + * @param writer Callback to write data from a buffer, returning bytes written. + * @param seeker Callback to seek to a position, returning the new position. + * @param flusher Callback to flush the stream, returning 0 on success. + * @return A CallbackStream configured for both reading and writing. + */ + fun forReadWrite( + reader: StreamReader, + writer: StreamWriter, + seeker: StreamSeeker, + flusher: StreamFlusher, + ): CallbackStream = CallbackStream( + reader = reader, + writer = writer, + seeker = seeker, + flusher = flusher, + ) + } } /** File-based stream implementation */ @@ -223,17 +284,13 @@ class FileStream(fileURL: File, mode: Mode = Mode.READ_WRITE, createIfNeeded: Bo * output. */ class ByteArrayStream(initialData: ByteArray? = null) : Stream() { - private val buffer = ByteArrayOutputStream() + private var data: ByteArray = initialData?.copyOf() ?: ByteArray(0) private var position = 0 - private var data: ByteArray = initialData ?: ByteArray(0) - - init { - initialData?.let { buffer.write(it) } - } + private var size = data.size override fun read(buffer: ByteArray, length: Long): Long { - if (position >= data.size) return 0 - val toRead = minOf(length.toInt(), data.size - position) + if (position >= size) return 0 + val toRead = minOf(length.toInt(), size - position) System.arraycopy(data, position, buffer, 0, toRead) position += toRead return toRead.toLong() @@ -244,42 +301,37 @@ class ByteArrayStream(initialData: ByteArray? = null) : Stream() { when (mode) { SeekMode.START.value -> offset.toInt() SeekMode.CURRENT.value -> position + offset.toInt() - SeekMode.END.value -> data.size + offset.toInt() + SeekMode.END.value -> size + offset.toInt() else -> return -1L } - position = position.coerceIn(0, data.size) + position = position.coerceIn(0, size) return position.toLong() } - override fun write(writeData: ByteArray, length: Long): Long { + override fun write(data: ByteArray, length: Long): Long { val len = length.toInt() - if (position < data.size) { - // Writing in the middle - need to handle carefully - val newData = data.toMutableList() - for (i in 0 until len) { - if (position + i < newData.size) { - newData[position + i] = writeData[i] - } else { - newData.add(writeData[i]) - } - } - data = newData.toByteArray() - buffer.reset() - buffer.write(data) - } else { - // Appending - buffer.write(writeData, 0, len) - data = buffer.toByteArray() + val requiredCapacity = position + len + + // Expand buffer if needed (grow by 2x or to required size, whichever is larger) + if (requiredCapacity > this.data.size) { + val newCapacity = maxOf(this.data.size * 2, requiredCapacity) + this.data = this.data.copyOf(newCapacity) } + + // Copy data directly into buffer + System.arraycopy(data, 0, this.data, position, len) position += len - return length - } - override fun flush(): Long { - data = buffer.toByteArray() - return 0 + // Update size if we wrote past the current end + if (position > size) { + size = position + } + + return len.toLong() } + override fun flush(): Long = 0 + /** Get the current data in the stream */ - fun getData(): ByteArray = data + fun getData(): ByteArray = data.copyOf(size) } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt index 9419104..c47d696 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -14,7 +14,6 @@ package org.contentauth.c2pa import android.util.Base64 import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -39,12 +38,6 @@ class WebServiceSigner( private val bearerToken: String? = null, private val customHeaders: Map = emptyMap(), ) { - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - private val httpClient = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -98,13 +91,13 @@ class WebServiceSigner( val responseBody = response.body?.string() ?: throw SignerException.InvalidResponse - return json.decodeFromString(responseBody) + return C2PAJson.default.decodeFromString(responseBody) } private fun signData(data: ByteArray, signingURL: String): ByteArray { val dataToSignBase64 = Base64.encodeToString(data, Base64.NO_WRAP) val requestJson = - json.encodeToString(SignRequest.serializer(), SignRequest(claim = dataToSignBase64)) + C2PAJson.default.encodeToString(SignRequest.serializer(), SignRequest(claim = dataToSignBase64)) val requestBuilder = Request.Builder() @@ -124,7 +117,7 @@ class WebServiceSigner( } val responseBody = response.body?.string() ?: throw SignerException.InvalidResponse - val signResponse = json.decodeFromString(responseBody) + val signResponse = C2PAJson.default.decodeFromString(responseBody) return Base64.decode(signResponse.signature, Base64.NO_WRAP) } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/SettingsValidator.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/SettingsValidator.kt new file mode 100644 index 0000000..c9eac93 --- /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/rust-sdk/docs/context-settings + * + * ## Usage + * + * ```kotlin + * val settingsJson = """{"version": 1, "verify": {"verify_trust": false}}""" + * val result = SettingsValidator.validate(settingsJson) + * if (result.hasErrors()) { + * result.errors.forEach { println("Error: $it") } + * } + * if (result.hasWarnings()) { + * result.warnings.forEach { println("Warning: $it") } + * } + * ``` + */ +object SettingsValidator { + + private const val TAG = "C2PA" + + /** + * Currently supported settings format version. + */ + const val SUPPORTED_VERSION = 1 + + /** + * Valid signing algorithms for C2PA, derived from [SigningAlgorithm] enum values. + */ + val VALID_ALGORITHMS: Set = SigningAlgorithm.entries.map { it.description }.toSet() + + /** + * Valid thumbnail formats. + */ + val VALID_THUMBNAIL_FORMATS: Set = setOf("jpeg", "png", "webp") + + /** + * Valid thumbnail quality settings. + */ + val VALID_THUMBNAIL_QUALITIES: Set = setOf("low", "medium", "high") + + /** + * Valid intent string values (non-object form). + */ + val VALID_INTENT_STRINGS: Set = setOf("Edit", "Update") + + /** + * Valid digital source types for actions. + */ + val VALID_SOURCE_TYPES: Set = setOf( + "empty", + "digitalCapture", + "negativeFilm", + "positiveFilm", + "print", + "minorHumanEdits", + "compositeCapture", + "algorithmicallyEnhanced", + "dataDrivenMedia", + "digitalArt", + "compositeWithTrainedAlgorithmicMedia", + "compositeSynthetic", + "trainedAlgorithmicMedia", + "algorithmicMedia", + "virtualRecording", + "composite", + "softwareRendered", + "generatedByAI", + ) + + /** + * Known top-level settings sections. + */ + val KNOWN_TOP_LEVEL_KEYS: Set = setOf( + "version", + "trust", + "cawg_trust", + "core", + "verify", + "builder", + "signer", + "cawg_x509_signer", + ) + + /** + * Validates a settings JSON string. + * + * @param settingsJson The settings JSON string to validate. + * @param logWarnings If true (default), warnings are logged to the Android console. + * @return A ValidationResult with any errors or warnings found. + */ + fun validate(settingsJson: String, logWarnings: Boolean = true): ValidationResult { + val errors = mutableListOf() + val warnings = mutableListOf() + + try { + val jsonObject = C2PAJson.default.parseToJsonElement(settingsJson).jsonObject + validateSettingsObject(jsonObject, errors, warnings) + } catch (e: Exception) { + errors.add("Failed to parse settings JSON: ${e.message}") + } + + if (logWarnings) { + logValidationResults(errors, warnings) + } + + return ValidationResult(errors, warnings) + } + + /** + * Validates a parsed settings JSON object. + */ + private fun validateSettingsObject( + settings: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + // Check for unknown top-level keys + settings.keys.forEach { key -> + if (key !in KNOWN_TOP_LEVEL_KEYS) { + warnings.add("Unknown top-level key: '$key'") + } + } + + // Validate version + validateVersion(settings, errors) + + // Validate each section + settings["trust"]?.jsonObject?.let { validateTrustSection(it, "trust", errors, warnings) } + settings["cawg_trust"]?.jsonObject?.let { validateCawgTrustSection(it, errors, warnings) } + settings["core"]?.jsonObject?.let { validateCoreSection(it, errors, warnings) } + settings["verify"]?.jsonObject?.let { validateVerifySection(it, errors, warnings) } + settings["builder"]?.jsonObject?.let { validateBuilderSection(it, errors, warnings) } + settings["signer"]?.jsonObject?.let { validateSignerSection(it, "signer", errors, warnings) } + settings["cawg_x509_signer"]?.jsonObject?.let { + validateSignerSection(it, "cawg_x509_signer", errors, warnings) + } + } + + /** + * Validates the version field. + */ + private fun validateVersion(settings: JsonObject, errors: MutableList) { + val version = settings["version"]?.jsonPrimitive?.intOrNull + if (version == null) { + errors.add("'version' is required and must be an integer") + } else if (version != SUPPORTED_VERSION) { + errors.add("'version' must be $SUPPORTED_VERSION, got $version") + } + } + + /** + * Validates trust section (shared structure for trust and cawg_trust). + */ + private fun validateTrustSection( + trust: JsonObject, + sectionName: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("user_anchors", "trust_anchors", "trust_config", "allowed_list", "verify_trust_list") + + trust.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $sectionName: '$key'") + } + } + + // Validate PEM format for certificate fields + listOf("user_anchors", "trust_anchors", "allowed_list").forEach { field -> + trust[field]?.jsonPrimitive?.content?.let { pemString -> + if (!isValidPEM(pemString, "CERTIFICATE")) { + errors.add("$sectionName.$field must be valid PEM-formatted certificate(s)") + } + } + } + } + + /** + * Validates cawg_trust section. + */ + private fun validateCawgTrustSection( + cawgTrust: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + validateTrustSection(cawgTrust, "cawg_trust", errors, warnings) + + // verify_trust_list specific to cawg_trust + cawgTrust["verify_trust_list"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("cawg_trust.verify_trust_list must be a boolean") + } + } + } + + /** + * Validates core section. + */ + private fun validateCoreSection( + core: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "merkle_tree_chunk_size_in_kb", + "merkle_tree_max_proofs", + "backing_store_memory_threshold_in_mb", + "decode_identity_assertions", + "allowed_network_hosts", + ) + + core.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in core: '$key'") + } + } + + // Validate numeric fields + listOf( + "merkle_tree_chunk_size_in_kb", + "merkle_tree_max_proofs", + "backing_store_memory_threshold_in_mb", + ).forEach { field -> + core[field]?.let { element -> + if (element.jsonPrimitive.intOrNull == null) { + errors.add("core.$field must be a number") + } + } + } + + // Validate boolean field + core["decode_identity_assertions"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("core.decode_identity_assertions must be a boolean") + } + } + + // Validate allowed_network_hosts is an array + core["allowed_network_hosts"]?.let { element -> + try { + element.jsonArray + } catch (e: Exception) { + errors.add("core.allowed_network_hosts must be an array") + } + } + } + + /** + * Validates verify section. + */ + private fun validateVerifySection( + verify: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "verify_after_reading", + "verify_after_sign", + "verify_trust", + "verify_timestamp_trust", + "ocsp_fetch", + "remote_manifest_fetch", + "skip_ingredient_conflict_resolution", + "strict_v1_validation", + ) + + verify.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in verify: '$key'") + } + } + + // All verify fields are booleans + validKeys.forEach { field -> + verify[field]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("verify.$field must be a boolean") + } + } + } + + // Warn about disabling verification + listOf("verify_trust", "verify_timestamp_trust", "verify_after_sign").forEach { field -> + verify[field]?.jsonPrimitive?.booleanOrNull?.let { value -> + if (!value) { + warnings.add( + "verify.$field is set to false. This may result in verification behavior " + + "that is not fully compliant with the C2PA specification.", + ) + } + } + } + } + + /** + * Validates builder section. + */ + private fun validateBuilderSection( + builder: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "claim_generator_info", + "certificate_status_fetch", + "certificate_status_should_override", + "intent", + "created_assertion_labels", + "generate_c2pa_archive", + "actions", + "thumbnail", + ) + + builder.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in builder: '$key'") + } + } + + // Validate intent + builder["intent"]?.let { validateIntent(it, errors) } + + // Validate thumbnail + builder["thumbnail"]?.jsonObject?.let { validateThumbnailSection(it, errors, warnings) } + + // Validate actions + builder["actions"]?.jsonObject?.let { validateActionsSection(it, errors, warnings) } + + // Validate claim_generator_info + builder["claim_generator_info"]?.jsonObject?.let { info -> + if (info["name"] == null) { + errors.add("builder.claim_generator_info.name is required when claim_generator_info is specified") + } + } + + // Validate created_assertion_labels is an array + builder["created_assertion_labels"]?.let { element -> + try { + element.jsonArray + } catch (e: Exception) { + errors.add("builder.created_assertion_labels must be an array") + } + } + + // Validate boolean fields + builder["generate_c2pa_archive"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("builder.generate_c2pa_archive must be a boolean") + } + } + } + + /** + * Validates intent value. + */ + private fun validateIntent(intent: JsonElement, errors: MutableList) { + when { + intent is JsonPrimitive && intent.isString -> { + val intentString = intent.content + if (intentString !in VALID_INTENT_STRINGS) { + errors.add( + "builder.intent string must be one of: ${VALID_INTENT_STRINGS.joinToString()}, " + + "got '$intentString'", + ) + } + } + intent is JsonObject -> { + // Should be {"Create": "sourceType"} + val createValue = intent["Create"]?.jsonPrimitive?.content + if (createValue == null) { + errors.add("builder.intent object must have 'Create' key with source type value") + } else if (createValue !in VALID_SOURCE_TYPES) { + errors.add( + "builder.intent Create source type must be one of: ${VALID_SOURCE_TYPES.joinToString()}, " + + "got '$createValue'", + ) + } + } + else -> { + errors.add("builder.intent must be a string ('Edit', 'Update') or object ({\"Create\": \"sourceType\"})") + } + } + } + + /** + * Validates thumbnail section. + */ + private fun validateThumbnailSection( + thumbnail: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("enabled", "ignore_errors", "long_edge", "format", "prefer_smallest_format", "quality") + + thumbnail.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in builder.thumbnail: '$key'") + } + } + + // Validate format + thumbnail["format"]?.jsonPrimitive?.content?.let { format -> + if (format !in VALID_THUMBNAIL_FORMATS) { + errors.add( + "builder.thumbnail.format must be one of: ${VALID_THUMBNAIL_FORMATS.joinToString()}, " + + "got '$format'", + ) + } + } + + // Validate quality + thumbnail["quality"]?.jsonPrimitive?.content?.let { quality -> + if (quality !in VALID_THUMBNAIL_QUALITIES) { + errors.add( + "builder.thumbnail.quality must be one of: ${VALID_THUMBNAIL_QUALITIES.joinToString()}, " + + "got '$quality'", + ) + } + } + + // Validate long_edge is a number + thumbnail["long_edge"]?.let { element -> + if (element.jsonPrimitive.intOrNull == null) { + errors.add("builder.thumbnail.long_edge must be a number") + } + } + + // Validate boolean fields + listOf("enabled", "ignore_errors", "prefer_smallest_format").forEach { field -> + thumbnail[field]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("builder.thumbnail.$field must be a boolean") + } + } + } + } + + /** + * Validates actions section. + */ + private fun validateActionsSection( + actions: JsonObject, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf( + "all_actions_included", + "templates", + "actions", + "auto_created_action", + "auto_opened_action", + "auto_placed_action", + ) + + actions.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in builder.actions: '$key'") + } + } + + // Validate auto action sections + listOf("auto_created_action", "auto_opened_action", "auto_placed_action").forEach { actionType -> + actions[actionType]?.jsonObject?.let { autoAction -> + validateAutoAction(autoAction, "builder.actions.$actionType", errors, warnings) + } + } + } + + /** + * Validates auto action configuration. + */ + private fun validateAutoAction( + autoAction: JsonObject, + path: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("enabled", "source_type") + + autoAction.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $path: '$key'") + } + } + + // Validate enabled is boolean + autoAction["enabled"]?.let { element -> + if (element.jsonPrimitive.booleanOrNull == null) { + errors.add("$path.enabled must be a boolean") + } + } + + // Validate source_type + autoAction["source_type"]?.jsonPrimitive?.content?.let { sourceType -> + if (sourceType !in VALID_SOURCE_TYPES) { + errors.add( + "$path.source_type must be one of: ${VALID_SOURCE_TYPES.joinToString()}, " + + "got '$sourceType'", + ) + } + } + } + + /** + * Validates signer section (local or remote). + */ + private fun validateSignerSection( + signer: JsonObject, + sectionName: String, + errors: MutableList, + warnings: MutableList, + ) { + val hasLocal = signer["local"] != null + val hasRemote = signer["remote"] != null + + if (hasLocal && hasRemote) { + errors.add("$sectionName cannot have both 'local' and 'remote' configurations") + } + + if (!hasLocal && !hasRemote) { + errors.add("$sectionName must have either 'local' or 'remote' configuration") + } + + signer["local"]?.jsonObject?.let { local -> + validateLocalSigner(local, "$sectionName.local", errors, warnings) + } + + signer["remote"]?.jsonObject?.let { remote -> + validateRemoteSigner(remote, "$sectionName.remote", errors, warnings) + } + } + + /** + * Validates local signer configuration. + */ + private fun validateLocalSigner( + local: JsonObject, + path: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("alg", "sign_cert", "private_key", "tsa_url") + + local.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $path: '$key'") + } + } + + // Required fields + if (local["alg"] == null) { + errors.add("$path.alg is required") + } + if (local["sign_cert"] == null) { + errors.add("$path.sign_cert is required") + } + if (local["private_key"] == null) { + errors.add("$path.private_key is required") + } + + // Validate algorithm + local["alg"]?.jsonPrimitive?.content?.let { alg -> + if (alg.lowercase() !in VALID_ALGORITHMS) { + errors.add( + "$path.alg must be one of: ${VALID_ALGORITHMS.joinToString()}, " + + "got '$alg'", + ) + } + } + + // Validate PEM formats + local["sign_cert"]?.jsonPrimitive?.content?.let { cert -> + if (!isValidPEM(cert, "CERTIFICATE")) { + errors.add("$path.sign_cert must be valid PEM-formatted certificate(s)") + } + } + + local["private_key"]?.jsonPrimitive?.content?.let { key -> + if (!isValidPEM(key, "PRIVATE KEY") && !isValidPEM(key, "RSA PRIVATE KEY") && + !isValidPEM(key, "EC PRIVATE KEY") + ) { + errors.add("$path.private_key must be valid PEM-formatted private key") + } + } + + // Validate TSA URL + local["tsa_url"]?.jsonPrimitive?.content?.let { url -> + if (!isValidUrl(url)) { + errors.add("$path.tsa_url must be a valid URL") + } + } + } + + /** + * Validates remote signer configuration. + */ + private fun validateRemoteSigner( + remote: JsonObject, + path: String, + errors: MutableList, + warnings: MutableList, + ) { + val validKeys = setOf("url", "alg", "sign_cert", "tsa_url") + + remote.keys.forEach { key -> + if (key !in validKeys) { + warnings.add("Unknown key in $path: '$key'") + } + } + + // Required fields + if (remote["url"] == null) { + errors.add("$path.url is required") + } + if (remote["alg"] == null) { + errors.add("$path.alg is required") + } + if (remote["sign_cert"] == null) { + errors.add("$path.sign_cert is required") + } + + // Validate URL + remote["url"]?.jsonPrimitive?.content?.let { url -> + if (!isValidUrl(url)) { + errors.add("$path.url must be a valid URL") + } + } + + // Validate algorithm + remote["alg"]?.jsonPrimitive?.content?.let { alg -> + if (alg.lowercase() !in VALID_ALGORITHMS) { + errors.add( + "$path.alg must be one of: ${VALID_ALGORITHMS.joinToString()}, " + + "got '$alg'", + ) + } + } + + // Validate PEM format for certificate + remote["sign_cert"]?.jsonPrimitive?.content?.let { cert -> + if (!isValidPEM(cert, "CERTIFICATE")) { + errors.add("$path.sign_cert must be valid PEM-formatted certificate(s)") + } + } + + // Validate TSA URL + remote["tsa_url"]?.jsonPrimitive?.content?.let { url -> + if (!isValidUrl(url)) { + errors.add("$path.tsa_url must be a valid URL") + } + } + } + + /** + * Checks if a string is valid PEM format. + */ + private fun isValidPEM(pemString: String, expectedType: String): Boolean { + val beginMarker = "-----BEGIN $expectedType-----" + val endMarker = "-----END $expectedType-----" + return pemString.contains(beginMarker) && pemString.contains(endMarker) + } + + /** + * Checks if a string is a valid URL. + */ + private fun isValidUrl(url: String): Boolean { + return try { + val parsed = java.net.URL(url) + parsed.protocol in listOf("http", "https") + } catch (e: Exception) { + false + } + } + + /** + * Logs validation results to the Android console. + */ + private fun logValidationResults(errors: List, warnings: List) { + errors.forEach { error -> + Log.e(TAG, "Settings validation error: $error") + } + warnings.forEach { warning -> + Log.w(TAG, "Settings validation warning: $warning") + } + } + + /** + * Validates settings and logs warnings. + * + * Convenience method that validates and logs in one call. + * + * @param settingsJson The settings JSON string to validate. + * @return A ValidationResult with any errors or warnings found. + */ + fun validateAndLog(settingsJson: String): ValidationResult = validate(settingsJson, logWarnings = true) +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt new file mode 100644 index 0000000..2e338f6 --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ValidationResult.kt @@ -0,0 +1,37 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa.manifest + +/** + * Result of validation containing errors and warnings. + * + * Used by [ManifestValidator] and [SettingsValidator] to report validation outcomes. + * + * @property errors Critical issues that violate spec or schema requirements. + * @property warnings Non-critical issues that may indicate misuse or misconfiguration. + * @see ManifestValidator + * @see SettingsValidator + */ +data class ValidationResult( + val errors: List = emptyList(), + val warnings: List = emptyList(), +) { + /** Returns true if there are any errors. */ + fun hasErrors(): Boolean = errors.isNotEmpty() + + /** Returns true if there are any warnings. */ + fun hasWarnings(): Boolean = warnings.isNotEmpty() + + /** Returns true if validation passed without errors. */ + fun isValid(): Boolean = !hasErrors() +} diff --git a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt index c200f92..8d0c374 100644 --- a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt +++ b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.withContext import org.contentauth.c2pa.test.shared.BuilderTests import org.contentauth.c2pa.test.shared.CoreTests import org.contentauth.c2pa.test.shared.ManifestTests +import org.contentauth.c2pa.test.shared.SettingsValidatorTests import org.contentauth.c2pa.test.shared.SignerTests import org.contentauth.c2pa.test.shared.StreamTests import org.contentauth.c2pa.test.shared.TestBase @@ -129,6 +130,16 @@ private class AppWebServiceTests(private val context: Context) : WebServiceTests copyResourceToCache(context, resourceName, fileName) } +private class AppSettingsValidatorTests(private val context: Context) : SettingsValidatorTests() { + override fun getContext(): Context = context + override fun loadResourceAsBytes(resourceName: String): ByteArray = loadResourceWithExtensions(resourceName) + ?: throw IllegalArgumentException("Resource not found: $resourceName") + override fun loadResourceAsString(resourceName: String): String = loadResourceStringWithExtensions(resourceName) + ?: throw IllegalArgumentException("Resource not found: $resourceName") + override fun copyResourceToFile(resourceName: String, fileName: String): File = + copyResourceToCache(context, resourceName, fileName) +} + private class AppManifestTests(private val context: Context) : ManifestTests() { override fun getContext(): Context = context override fun loadResourceAsBytes(resourceName: String): ByteArray = loadResourceWithExtensions(resourceName) @@ -204,7 +215,9 @@ private suspend fun runAllTests(context: Context): List = withContex results.add(coreTests.testConcurrentOperations()) results.add(coreTests.testReaderResourceErrorHandling()) - // Additional Stream Tests (large buffer handling) + // Additional Stream Tests + results.add(streamTests.testCallbackStreamFactories()) + results.add(streamTests.testByteArrayStreamBufferGrowth()) results.add(streamTests.testLargeBufferHandling()) // Manifest Tests @@ -229,6 +242,27 @@ private suspend fun runAllTests(context: Context): List = withContex results.add(manifestTests.testAllValidationStatusCodes()) results.add(manifestTests.testAllDigitalSourceTypes()) + // Settings Validator Tests + val settingsValidatorTests = AppSettingsValidatorTests(context) + results.add(settingsValidatorTests.testValidSettings()) + results.add(settingsValidatorTests.testInvalidJson()) + results.add(settingsValidatorTests.testMissingVersion()) + results.add(settingsValidatorTests.testWrongVersion()) + results.add(settingsValidatorTests.testUnknownTopLevelKeys()) + results.add(settingsValidatorTests.testTrustSection()) + results.add(settingsValidatorTests.testCawgTrustSection()) + results.add(settingsValidatorTests.testCoreSection()) + results.add(settingsValidatorTests.testVerifySection()) + results.add(settingsValidatorTests.testBuilderSection()) + results.add(settingsValidatorTests.testThumbnailSection()) + results.add(settingsValidatorTests.testActionsSection()) + results.add(settingsValidatorTests.testLocalSigner()) + results.add(settingsValidatorTests.testRemoteSigner()) + results.add(settingsValidatorTests.testSignerMutualExclusion()) + results.add(settingsValidatorTests.testValidationResultHelpers()) + results.add(settingsValidatorTests.testValidateAndLog()) + results.add(settingsValidatorTests.testIntentAsNumber()) + results } diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt index 046d21f..1c69e01 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt @@ -20,6 +20,8 @@ import org.contentauth.c2pa.BuilderIntent import org.contentauth.c2pa.ByteArrayStream import org.contentauth.c2pa.C2PA import org.contentauth.c2pa.C2PAError +import org.contentauth.c2pa.C2PAContext +import org.contentauth.c2pa.C2PASettings import org.contentauth.c2pa.DigitalSourceType import org.contentauth.c2pa.FileStream import org.contentauth.c2pa.PredefinedAction @@ -39,52 +41,44 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") val sourceStream = ByteArrayStream(sourceImageData) val fileTest = File.createTempFile("c2pa-stream-api-test", ".jpg") val destStream = FileStream(fileTest) - try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - - val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) - val signer = Signer.fromInfo(signerInfo) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + + val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) + Signer.fromInfo(signerInfo).use { signer -> + val result = + builder.sign( + "image/jpeg", + sourceStream, + destStream, + signer, + ) - try { - val result = - builder.sign( - "image/jpeg", - sourceStream, - destStream, - signer, + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val success = json.has("manifests") + + TestResult( + "Builder API", + success, + if (success) { + "Successfully signed image" + } else { + "Signing failed" + }, + "Original: ${sourceImageData.size}, Signed: ${fileTest.length()}, Result size: ${result.size}\n\n$json", ) - - val manifest = C2PA.readFile(fileTest.absolutePath) - val json = JSONObject(manifest) - val success = json.has("manifests") - - TestResult( - "Builder API", - success, - if (success) { - "Successfully signed image" - } else { - "Signing failed" - }, - "Original: ${sourceImageData.size}, Signed: ${fileTest.length()}, Result size: ${result.size}\n\n$json", - ) - } finally { - signer.close() + } } - } finally { - sourceStream.close() - destStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult("Builder API", false, "Failed to create builder", e.toString()) @@ -97,11 +91,9 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> builder.setNoEmbed() - val archiveStream = ByteArrayStream() - try { + ByteArrayStream().use { archiveStream -> builder.toArchive(archiveStream) val data = archiveStream.getData() val success = data.isNotEmpty() @@ -115,11 +107,7 @@ abstract class BuilderTests : TestBase() { }, "Archive size: ${data.size}", ) - } finally { - archiveStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -135,34 +123,42 @@ abstract class BuilderTests : TestBase() { suspend fun testBuilderRemoteUrl(): TestResult = withContext(Dispatchers.IO) { runTest("Builder Remote URL") { val manifestJson = TEST_MANIFEST_JSON + val remoteUrl = "https://example.com/manifest.c2pa" try { - val builder = Builder.fromJson(manifestJson) - try { - builder.setRemoteURL("https://example.com/manifest.c2pa") - builder.setNoEmbed() - val archive = ByteArrayStream() + Builder.fromJson(manifestJson).use { builder -> + builder.setRemoteURL(remoteUrl) + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-remote-url-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveData = archive.getData() - val archiveStr = String(archiveData) - val success = - archiveStr.contains("https://example.com/manifest.c2pa") - TestResult( - "Builder Remote URL", - success, - if (success) { - "Remote URL set successfully" - } else { - "Remote URL not found in archive" - }, - "Archive contains URL: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + val signResult = builder.sign("image/jpeg", sourceStream, destStream, signer) + val hasManifestBytes = signResult.manifestBytes != null && signResult.manifestBytes!!.isNotEmpty() + val success = signResult.size > 0 && hasManifestBytes + TestResult( + "Builder Remote URL", + success, + if (success) { + "Remote URL set successfully" + } else { + "Remote signing failed" + }, + "Sign result size: ${signResult.size}, Has manifest bytes: $hasManifestBytes", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -180,36 +176,42 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val thumbnailData = createSimpleJPEGThumbnail() - val thumbnailStream = ByteArrayStream(thumbnailData) - try { + ByteArrayStream(thumbnailData).use { thumbnailStream -> builder.addResource("thumbnail", thumbnailStream) - builder.setNoEmbed() - val archive = ByteArrayStream() + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-resource-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveStr = String(archive.getData()) - val success = archiveStr.contains("thumbnail") - TestResult( - "Builder Add Resource", - success, - if (success) { - "Resource added successfully" - } else { - "Resource not found in archive" - }, - "Thumbnail size: ${thumbnailData.size} bytes, Found in archive: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("thumbnail") + TestResult( + "Builder Add Resource", + success, + if (success) { + "Resource added successfully" + } else { + "Resource not found in signed manifest" + }, + "Thumbnail size: ${thumbnailData.size} bytes, Found in manifest: $success", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - thumbnailStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -227,43 +229,48 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val ingredientJson = """{"title": "Test Ingredient", "format": "image/jpeg"}""" val ingredientImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val ingredientStream = ByteArrayStream(ingredientImageData) - try { + ByteArrayStream(ingredientImageData).use { ingredientStream -> builder.addIngredient( ingredientJson, "image/jpeg", ingredientStream, ) - builder.setNoEmbed() - val archive = ByteArrayStream() + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-ingredient-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveStr = String(archive.getData()) - val success = - archiveStr.contains("\"title\":\"Test Ingredient\"") - TestResult( - "Builder Add Ingredient", - success, - if (success) { - "Ingredient added successfully" - } else { - "Ingredient not found in archive" - }, - "Ingredient found: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("Test Ingredient") + TestResult( + "Builder Add Ingredient", + success, + if (success) { + "Ingredient added successfully" + } else { + "Ingredient not found in signed manifest" + }, + "Ingredient found: $success", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - ingredientStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -281,32 +288,28 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val originalBuilder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { originalBuilder -> val thumbnailData = createSimpleJPEGThumbnail() - val thumbnailStream = ByteArrayStream(thumbnailData) - originalBuilder.addResource("test_thumbnail", thumbnailStream) - thumbnailStream.close() + ByteArrayStream(thumbnailData).use { thumbnailStream -> + originalBuilder.addResource("test_thumbnail", thumbnailStream) + } originalBuilder.setNoEmbed() - val archiveStream = ByteArrayStream() - try { + ByteArrayStream().use { archiveStream -> originalBuilder.toArchive(archiveStream) val archiveData = archiveStream.getData() - val newArchiveStream = ByteArrayStream(archiveData) - var builderCreated = false - try { - val newBuilder = Builder.fromArchive(newArchiveStream) - builderCreated = true - newBuilder.close() - } catch (e: Exception) { - builderCreated = false + ByteArrayStream(archiveData).use { newArchiveStream -> + try { + Builder.fromArchive(newArchiveStream).use { + builderCreated = true + } + } catch (e: Exception) { + builderCreated = false + } } - newArchiveStream.close() - val hasData = archiveData.isNotEmpty() val success = hasData && builderCreated @@ -321,11 +324,7 @@ abstract class BuilderTests : TestBase() { }, "Archive size: ${archiveData.size} bytes, Builder created: $builderCreated", ) - } finally { - archiveStream.close() } - } finally { - originalBuilder.close() } } catch (e: Exception) { TestResult( @@ -343,55 +342,44 @@ abstract class BuilderTests : TestBase() { try { val manifestJson = TEST_MANIFEST_JSON - val builder = Builder.fromJson(manifestJson) + val fileTest = File.createTempFile("c2pa-manifest-direct-sign", ".jpg") try { - val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val sourceStream = ByteArrayStream(sourceImageData) - val fileTest = File.createTempFile("c2pa-manifest-direct-sign", ".jpg") - val destStream = FileStream(fileTest) - - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = - Signer.fromInfo( - SignerInfo(SigningAlgorithm.ES256, certPem, keyPem), - ) - - val signResult = - builder.sign("image/jpeg", sourceStream, destStream, signer) - - sourceStream.close() - destStream.close() - signer.close() + val signResult = Builder.fromJson(manifestJson).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(fileTest).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo( + SignerInfo(SigningAlgorithm.ES256, certPem, keyPem), + ).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + } + } + } + } val freshImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val freshStream = ByteArrayStream(freshImageData) - - val success = + val success = ByteArrayStream(freshImageData).use { freshStream -> if (signResult.manifestBytes != null) { try { - val reader = - Reader.fromManifestAndStream( - "image/jpeg", - freshStream, - signResult.manifestBytes!!, - ) - try { + Reader.fromManifestAndStream( + "image/jpeg", + freshStream, + signResult.manifestBytes!!, + ).use { reader -> val json = reader.json() - json.contains("\"c2pa.test\"") - } finally { - reader.close() + // Check for c2pa.created action which is in TEST_MANIFEST_JSON + json.contains("\"c2pa.created\"") } } catch (_: Exception) { false } } else { val manifest = C2PA.readFile(fileTest.absolutePath) - manifest.contains("\"c2pa.test\"") + manifest.contains("\"c2pa.created\"") } - - freshStream.close() - fileTest.delete() + } TestResult( "Reader with Manifest Data", @@ -404,7 +392,7 @@ abstract class BuilderTests : TestBase() { "Manifest bytes available: ${signResult.manifestBytes != null}, Test assertion found: $success", ) } finally { - builder.close() + fileTest.delete() } } catch (e: Exception) { TestResult( @@ -420,35 +408,33 @@ abstract class BuilderTests : TestBase() { suspend fun testJsonRoundTrip(): TestResult = withContext(Dispatchers.IO) { runTest("JSON Round-trip") { val testImageData = loadResourceAsBytes("adobe_20220124_ci") - val memStream = ByteArrayStream(testImageData) try { - val reader = Reader.fromStream("image/jpeg", memStream) - try { - val originalJson = reader.json() - val json1 = JSONObject(originalJson) - - // Extract just the manifest part for rebuilding - val manifestsValue = json1.opt("manifests") - val success = - when (manifestsValue) { - is JSONArray -> manifestsValue.length() > 0 - is JSONObject -> manifestsValue.length() > 0 - else -> false - } + ByteArrayStream(testImageData).use { memStream -> + Reader.fromStream("image/jpeg", memStream).use { reader -> + val originalJson = reader.json() + val json1 = JSONObject(originalJson) - TestResult( - "JSON Round-trip", - success, - if (success) { - "JSON parsed successfully" - } else { - "Failed to parse JSON" - }, - "Manifests type: ${manifestsValue?.javaClass?.simpleName}, Has content: $success", - ) - } finally { - reader.close() + // Extract just the manifest part for rebuilding + val manifestsValue = json1.opt("manifests") + val success = + when (manifestsValue) { + is JSONArray -> manifestsValue.length() > 0 + is JSONObject -> manifestsValue.length() > 0 + else -> false + } + + TestResult( + "JSON Round-trip", + success, + if (success) { + "JSON parsed successfully" + } else { + "Failed to parse JSON" + }, + "Manifests type: ${manifestsValue?.javaClass?.simpleName}, Has content: $success", + ) + } } } catch (e: C2PAError) { TestResult( @@ -457,8 +443,6 @@ abstract class BuilderTests : TestBase() { "Failed to read manifest", e.toString(), ) - } finally { - memStream.close() } } } @@ -468,8 +452,7 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> // Test Create intent with digital source type builder.setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) @@ -479,48 +462,438 @@ abstract class BuilderTests : TestBase() { val destStream = FileStream(fileTest) try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + + // Check for c2pa.created action which should be auto-added by Create intent + val manifestStr = manifest.lowercase() + val hasCreatedAction = manifestStr.contains("c2pa.created") || + manifestStr.contains("digitalcapture") + + TestResult( + "Builder Set Intent", + true, + "Intent set and signed successfully", + "Has created action or digital source: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: C2PAError) { + TestResult( + "Builder Set Intent", + false, + "Failed to set intent", + e.toString(), + ) + } catch (e: Exception) { + TestResult( + "Builder Set Intent", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } - try { - builder.sign("image/jpeg", sourceStream, destStream, signer) + suspend fun testBuilderFromContextWithSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder from Context with Settings") { + val manifestJson = TEST_MANIFEST_JSON - val manifest = C2PA.readFile(fileTest.absolutePath) - val json = JSONObject(manifest) + try { + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": ["c2pa.actions"] + } + } + """.trimIndent() + + val builder = C2PASettings.create().use { settings -> + settings.updateFromString(settingsJson, "json") + C2PAContext.fromSettings(settings).use { context -> + Builder.fromContext(context).withDefinition(manifestJson) + } + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-context-settings-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val hasManifests = json.has("manifests") + val hasCreatedAction = manifest.contains("c2pa.created") + + val success = hasManifests && hasCreatedAction + + TestResult( + "Builder from Context with Settings", + success, + if (success) { + "Context-based builder with settings works" + } else { + "Failed to sign with context-based builder" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: C2PAError) { + TestResult( + "Builder from Context with Settings", + false, + "Failed to create builder from context", + e.toString(), + ) + } catch (e: Exception) { + TestResult( + "Builder from Context with Settings", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testBuilderFromJsonWithSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder fromJson with C2PASettings") { + val manifestJson = TEST_MANIFEST_JSON + + try { + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": ["c2pa.actions"] + } + } + """.trimIndent() + + val builder = C2PASettings.create().use { settings -> + settings.updateFromString(settingsJson, "json") + Builder.fromJson(manifestJson, settings) + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-fromjson-settings-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val hasManifests = json.has("manifests") + val hasCreatedAction = manifest.contains("c2pa.created") + val success = hasManifests && hasCreatedAction + + TestResult( + "Builder fromJson with C2PASettings", + success, + if (success) { + "fromJson(manifest, settings) works" + } else { + "Failed to sign with fromJson(manifest, settings)" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: Exception) { + TestResult( + "Builder fromJson with C2PASettings", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testBuilderWithArchive(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder withArchive") { + val manifestJson = TEST_MANIFEST_JSON + + try { + val archiveData = Builder.fromJson(manifestJson).use { originalBuilder -> + originalBuilder.setNoEmbed() + ByteArrayStream().use { archiveStream -> + originalBuilder.toArchive(archiveStream) + archiveStream.getData() + } + } + + val newBuilder = C2PAContext.create().use { context -> + ByteArrayStream(archiveData).use { newArchiveStream -> + Builder.fromContext(context).withArchive(newArchiveStream) + } + } + + val signSuccess = newBuilder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-witharchive-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + newBuilder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + manifest.contains("c2pa.created") + } + } + } + } finally { + fileTest.delete() + } + } + + val success = archiveData.isNotEmpty() && signSuccess + TestResult( + "Builder withArchive", + success, + if (success) { + "withArchive round-trip successful" + } else { + "withArchive round-trip failed" + }, + "Archive size: ${archiveData.size}, Sign success: $signSuccess", + ) + } catch (e: Exception) { + TestResult( + "Builder withArchive", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testReaderFromContext(): TestResult = withContext(Dispatchers.IO) { + runTest("Reader fromContext with withStream") { + try { + // First, sign an image so we have something to read + val fileTest = File.createTempFile("c2pa-reader-context-test", ".jpg") + try { + Builder.fromJson(TEST_MANIFEST_JSON).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(fileTest).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + } + } + } + } - // Check for c2pa.created action which should be auto-added by Create intent - val manifestStr = manifest.lowercase() - val hasCreatedAction = manifestStr.contains("c2pa.created") || - manifestStr.contains("digitalcapture") + // Now read using the context-based API + val signedData = fileTest.readBytes() + ByteArrayStream(signedData).use { signedStream -> + val reader = C2PAContext.create().use { context -> + Reader.fromContext(context).withStream("image/jpeg", signedStream) + } + + reader.use { + val json = reader.json() + val hasManifests = json.contains("manifests") + val hasCreatedAction = json.contains("c2pa.created") + val isEmbedded = reader.isEmbedded() + val remoteUrl = reader.remoteUrl() + + val success = hasManifests && hasCreatedAction && isEmbedded && remoteUrl == null TestResult( - "Builder Set Intent", - true, - "Intent set and signed successfully", - "Has created action or digital source: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + "Reader fromContext with withStream", + success, + if (success) { + "Context-based reader works" + } else { + "Context-based reader failed" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction, " + + "Is embedded: $isEmbedded, Remote URL: $remoteUrl", ) - } finally { - signer.close() + } + } + } finally { + fileTest.delete() + } + } catch (e: Exception) { + TestResult( + "Reader fromContext with withStream", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testSettingsSetValue(): TestResult = withContext(Dispatchers.IO) { + runTest("C2PASettings setValue") { + try { + val builder = C2PASettings.create().use { settings -> + settings.updateFromString("""{"version": 1}""", "json") + .setValue("verify.verify_after_sign", "false") + C2PAContext.fromSettings(settings).use { context -> + Builder.fromContext(context) + .withDefinition(TEST_MANIFEST_JSON) + } + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-setvalue-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("manifests") + + TestResult( + "C2PASettings setValue", + success, + if (success) { + "setValue works for building context" + } else { + "setValue failed" + }, + "Signed with setValue-configured settings", + ) + } + } } } finally { - sourceStream.close() - destStream.close() fileTest.delete() } - } finally { - builder.close() } - } catch (e: C2PAError) { + } catch (e: Exception) { TestResult( - "Builder Set Intent", + "C2PASettings setValue", false, - "Failed to set intent", + "Exception: ${e.message}", e.toString(), ) + } + } + } + + suspend fun testBuilderIntentEditAndUpdate(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder Intent Edit and Update") { + try { + Builder.fromJson(TEST_MANIFEST_JSON).use { builder -> + builder.setIntent(BuilderIntent.Edit) + + // Add a parent ingredient (required for Edit) + val ingredientImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(ingredientImageData).use { ingredientStream -> + builder.addIngredient( + """{"title": "Parent Image", "format": "image/jpeg"}""", + "image/jpeg", + ingredientStream, + ) + } + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-edit-intent-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val editSuccess = manifest.contains("manifests") + + // Test Update intent + Builder.fromJson(TEST_MANIFEST_JSON).use { builder2 -> + builder2.setIntent(BuilderIntent.Update) + + val updateSuccess = true // setIntent didn't throw + + val success = editSuccess && updateSuccess + + TestResult( + "Builder Intent Edit and Update", + success, + if (success) { + "Edit and Update intents work" + } else { + "Intent test failed" + }, + "Edit signed: $editSuccess, Update set: $updateSuccess", + ) + } + } + } + } + } finally { + fileTest.delete() + } + } } catch (e: Exception) { TestResult( - "Builder Set Intent", + "Builder Intent Edit and Update", false, "Exception: ${e.message}", e.toString(), @@ -534,8 +907,7 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> // Add multiple actions builder.addAction( Action( @@ -564,42 +936,38 @@ abstract class BuilderTests : TestBase() { val destStream = FileStream(fileTest) try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)) - - try { - builder.sign("image/jpeg", sourceStream, destStream, signer) - - val manifest = C2PA.readFile(fileTest.absolutePath) - val manifestLower = manifest.lowercase() - - val hasEditedAction = manifestLower.contains("c2pa.edited") - val hasCroppedAction = manifestLower.contains("c2pa.cropped") - val hasCustomAction = manifestLower.contains("com.example.custom_action") - - val success = hasEditedAction && hasCroppedAction && hasCustomAction - - TestResult( - "Builder Add Action", - success, - if (success) { - "All actions added successfully" - } else { - "Some actions missing" - }, - "Edited: $hasEditedAction, Cropped: $hasCroppedAction, Custom: $hasCustomAction\nManifest preview: ${manifest.take(500)}...", - ) - } finally { - signer.close() + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val manifestLower = manifest.lowercase() + + val hasEditedAction = manifestLower.contains("c2pa.edited") + val hasCroppedAction = manifestLower.contains("c2pa.cropped") + val hasCustomAction = manifestLower.contains("com.example.custom_action") + + val success = hasEditedAction && hasCroppedAction && hasCustomAction + + TestResult( + "Builder Add Action", + success, + if (success) { + "All actions added successfully" + } else { + "Some actions missing" + }, + "Edited: $hasEditedAction, Cropped: $hasCroppedAction, Custom: $hasCustomAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } } } finally { - sourceStream.close() - destStream.close() fileTest.delete() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt new file mode 100644 index 0000000..21fbc6f --- /dev/null +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsValidatorTests.kt @@ -0,0 +1,1081 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa.test.shared + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.contentauth.c2pa.manifest.SettingsValidator +import org.contentauth.c2pa.manifest.ValidationResult + +/** Tests for SettingsValidator and ValidationResult. */ +abstract class SettingsValidatorTests : TestBase() { + + companion object { + const val VALID_PEM_CERT = "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----" + const val VALID_PEM_KEY = "-----BEGIN PRIVATE KEY-----\nMIIBtest\n-----END PRIVATE KEY-----" + const val VALID_PEM_EC_KEY = "-----BEGIN EC PRIVATE KEY-----\nMIIBtest\n-----END EC PRIVATE KEY-----" + const val VALID_PEM_RSA_KEY = "-----BEGIN RSA PRIVATE KEY-----\nMIIBtest\n-----END RSA PRIVATE KEY-----" + } + + suspend fun testValidSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Valid Settings") { + val settingsJson = """{"version": 1}""" + val result = SettingsValidator.validate(settingsJson, logWarnings = false) + + val success = result.isValid() && !result.hasErrors() && !result.hasWarnings() + TestResult( + "Valid Settings", + success, + if (success) "Minimal valid settings accepted" else "Unexpected validation failures", + "Errors: ${result.errors}, Warnings: ${result.warnings}", + ) + } + } + + suspend fun testInvalidJson(): TestResult = withContext(Dispatchers.IO) { + runTest("Invalid JSON") { + val result = SettingsValidator.validate("not valid json {{{", logWarnings = false) + + val success = result.hasErrors() && + result.errors.any { it.contains("Failed to parse") } + TestResult( + "Invalid JSON", + success, + if (success) "Malformed JSON correctly rejected" else "Expected parse error", + "Errors: ${result.errors}", + ) + } + } + + suspend fun testMissingVersion(): TestResult = withContext(Dispatchers.IO) { + runTest("Missing Version") { + val result = SettingsValidator.validate("""{}""", logWarnings = false) + + val success = result.hasErrors() && + result.errors.any { it.contains("version") } + TestResult( + "Missing Version", + success, + if (success) "Missing version correctly detected" else "Expected version error", + "Errors: ${result.errors}", + ) + } + } + + suspend fun testWrongVersion(): TestResult = withContext(Dispatchers.IO) { + runTest("Wrong Version") { + val result = SettingsValidator.validate("""{"version": 2}""", logWarnings = false) + + val success = result.hasErrors() && + result.errors.any { it.contains("version") && it.contains("2") } + TestResult( + "Wrong Version", + success, + if (success) "Wrong version correctly rejected" else "Expected version error", + "Errors: ${result.errors}", + ) + } + } + + suspend fun testUnknownTopLevelKeys(): TestResult = withContext(Dispatchers.IO) { + runTest("Unknown Top-Level Keys") { + val settingsJson = """{"version": 1, "unknown_section": {}, "another_unknown": true}""" + val result = SettingsValidator.validate(settingsJson, logWarnings = false) + + val success = !result.hasErrors() && + result.hasWarnings() && + result.warnings.any { it.contains("unknown_section") } && + result.warnings.any { it.contains("another_unknown") } + TestResult( + "Unknown Top-Level Keys", + success, + if (success) "Unknown keys produce warnings" else "Expected warnings for unknown keys", + "Warnings: ${result.warnings}", + ) + } + } + + suspend fun testTrustSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Trust Section Validation") { + val errors = mutableListOf() + + // Valid trust section with PEM certificates + val validTrust = """{ + "version": 1, + "trust": { + "trust_anchors": "$VALID_PEM_CERT" + } + }""" + val validResult = SettingsValidator.validate(validTrust, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid trust section rejected: ${validResult.errors}") + } + + // Invalid PEM format + val invalidPem = """{ + "version": 1, + "trust": { + "trust_anchors": "not a PEM certificate" + } + }""" + val invalidResult = SettingsValidator.validate(invalidPem, logWarnings = false) + if (!invalidResult.hasErrors()) { + errors.add("Invalid PEM not detected") + } + + // Unknown key in trust + val unknownKey = """{ + "version": 1, + "trust": { + "unknown_trust_key": true + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown trust key did not produce warning") + } + + // user_anchors and allowed_list PEM validation + val multiPem = """{ + "version": 1, + "trust": { + "user_anchors": "$VALID_PEM_CERT", + "allowed_list": "not valid" + } + }""" + val multiResult = SettingsValidator.validate(multiPem, logWarnings = false) + if (!multiResult.hasErrors() || !multiResult.errors.any { it.contains("allowed_list") }) { + errors.add("Invalid allowed_list PEM not detected") + } + + val success = errors.isEmpty() + TestResult( + "Trust Section Validation", + success, + if (success) "Trust section validated correctly" else "Trust validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testCawgTrustSection(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWG Trust Section Validation") { + val errors = mutableListOf() + + // Valid cawg_trust with verify_trust_list boolean + val validCawg = """{ + "version": 1, + "cawg_trust": { + "verify_trust_list": true + } + }""" + val validResult = SettingsValidator.validate(validCawg, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid cawg_trust rejected: ${validResult.errors}") + } + + // Invalid verify_trust_list type + val invalidType = """{ + "version": 1, + "cawg_trust": { + "verify_trust_list": "not_a_boolean" + } + }""" + val invalidResult = SettingsValidator.validate(invalidType, logWarnings = false) + if (!invalidResult.hasErrors()) { + errors.add("Non-boolean verify_trust_list not detected") + } + + val success = errors.isEmpty() + TestResult( + "CAWG Trust Section Validation", + success, + if (success) "CAWG trust section validated correctly" else "CAWG trust validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testCoreSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Core Section Validation") { + val errors = mutableListOf() + + // Valid core section + val validCore = """{ + "version": 1, + "core": { + "merkle_tree_chunk_size_in_kb": 64, + "merkle_tree_max_proofs": 128, + "backing_store_memory_threshold_in_mb": 256, + "decode_identity_assertions": true, + "allowed_network_hosts": ["example.com"] + } + }""" + val validResult = SettingsValidator.validate(validCore, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid core section rejected: ${validResult.errors}") + } + + // Invalid numeric field + val invalidNumeric = """{ + "version": 1, + "core": { + "merkle_tree_chunk_size_in_kb": "not_a_number" + } + }""" + val numResult = SettingsValidator.validate(invalidNumeric, logWarnings = false) + if (!numResult.hasErrors()) { + errors.add("Non-numeric merkle_tree_chunk_size_in_kb not detected") + } + + // Invalid boolean field + val invalidBool = """{ + "version": 1, + "core": { + "decode_identity_assertions": "yes" + } + }""" + val boolResult = SettingsValidator.validate(invalidBool, logWarnings = false) + if (!boolResult.hasErrors()) { + errors.add("Non-boolean decode_identity_assertions not detected") + } + + // Invalid array field + val invalidArray = """{ + "version": 1, + "core": { + "allowed_network_hosts": "not_an_array" + } + }""" + val arrayResult = SettingsValidator.validate(invalidArray, logWarnings = false) + if (!arrayResult.hasErrors()) { + errors.add("Non-array allowed_network_hosts not detected") + } + + // Unknown key produces warning + val unknownKey = """{ + "version": 1, + "core": { + "unknown_core_key": 42 + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown core key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Core Section Validation", + success, + if (success) "Core section validated correctly" else "Core validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testVerifySection(): TestResult = withContext(Dispatchers.IO) { + runTest("Verify Section Validation") { + val errors = mutableListOf() + + // Valid verify section + val validVerify = """{ + "version": 1, + "verify": { + "verify_after_reading": true, + "verify_after_sign": true, + "verify_trust": true, + "verify_timestamp_trust": true, + "ocsp_fetch": false, + "remote_manifest_fetch": true, + "skip_ingredient_conflict_resolution": false, + "strict_v1_validation": false + } + }""" + val validResult = SettingsValidator.validate(validVerify, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid verify section rejected: ${validResult.errors}") + } + + // Non-boolean verify field + val invalidBool = """{ + "version": 1, + "verify": { + "verify_trust": "yes" + } + }""" + val boolResult = SettingsValidator.validate(invalidBool, logWarnings = false) + if (!boolResult.hasErrors()) { + errors.add("Non-boolean verify_trust not detected") + } + + // Disabled verification produces warnings + val disabledVerify = """{ + "version": 1, + "verify": { + "verify_trust": false, + "verify_timestamp_trust": false, + "verify_after_sign": false + } + }""" + val warnResult = SettingsValidator.validate(disabledVerify, logWarnings = false) + if (!warnResult.hasWarnings() || warnResult.warnings.size < 3) { + errors.add("Expected 3 warnings for disabled verification, got ${warnResult.warnings.size}") + } + + // Unknown verify key + val unknownKey = """{ + "version": 1, + "verify": { + "unknown_verify_key": true + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown verify key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Verify Section Validation", + success, + if (success) "Verify section validated correctly" else "Verify validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testBuilderSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder Section Validation") { + val errors = mutableListOf() + + // Valid intent as string + val editIntent = """{ + "version": 1, + "builder": { + "intent": "Edit" + } + }""" + val editResult = SettingsValidator.validate(editIntent, logWarnings = false) + if (editResult.hasErrors()) { + errors.add("Valid Edit intent rejected: ${editResult.errors}") + } + + // Valid intent as object + val createIntent = """{ + "version": 1, + "builder": { + "intent": {"Create": "digitalCapture"} + } + }""" + val createResult = SettingsValidator.validate(createIntent, logWarnings = false) + if (createResult.hasErrors()) { + errors.add("Valid Create intent rejected: ${createResult.errors}") + } + + // Invalid intent string + val badIntent = """{ + "version": 1, + "builder": { + "intent": "Delete" + } + }""" + val badIntentResult = SettingsValidator.validate(badIntent, logWarnings = false) + if (!badIntentResult.hasErrors()) { + errors.add("Invalid intent string 'Delete' not detected") + } + + // Invalid intent object (missing Create key) + val badObj = """{ + "version": 1, + "builder": { + "intent": {"NotCreate": "digitalCapture"} + } + }""" + val badObjResult = SettingsValidator.validate(badObj, logWarnings = false) + if (!badObjResult.hasErrors()) { + errors.add("Intent object without Create key not detected") + } + + // Invalid intent Create source type + val badSource = """{ + "version": 1, + "builder": { + "intent": {"Create": "invalidSourceType"} + } + }""" + val badSourceResult = SettingsValidator.validate(badSource, logWarnings = false) + if (!badSourceResult.hasErrors()) { + errors.add("Invalid Create source type not detected") + } + + // claim_generator_info without name + val noName = """{ + "version": 1, + "builder": { + "claim_generator_info": {"version": "1.0"} + } + }""" + val noNameResult = SettingsValidator.validate(noName, logWarnings = false) + if (!noNameResult.hasErrors()) { + errors.add("claim_generator_info without name not detected") + } + + // created_assertion_labels not an array + val badLabels = """{ + "version": 1, + "builder": { + "created_assertion_labels": "not_an_array" + } + }""" + val labelsResult = SettingsValidator.validate(badLabels, logWarnings = false) + if (!labelsResult.hasErrors()) { + errors.add("Non-array created_assertion_labels not detected") + } + + // generate_c2pa_archive not boolean + val badArchive = """{ + "version": 1, + "builder": { + "generate_c2pa_archive": "yes" + } + }""" + val archiveResult = SettingsValidator.validate(badArchive, logWarnings = false) + if (!archiveResult.hasErrors()) { + errors.add("Non-boolean generate_c2pa_archive not detected") + } + + // Unknown builder key + val unknownKey = """{ + "version": 1, + "builder": { + "unknown_builder_key": true + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown builder key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Builder Section Validation", + success, + if (success) "Builder section validated correctly" else "Builder validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testThumbnailSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Thumbnail Section Validation") { + val errors = mutableListOf() + + // Valid thumbnail + val validThumb = """{ + "version": 1, + "builder": { + "thumbnail": { + "enabled": true, + "format": "jpeg", + "quality": "medium", + "long_edge": 1024, + "ignore_errors": false, + "prefer_smallest_format": true + } + } + }""" + val validResult = SettingsValidator.validate(validThumb, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid thumbnail rejected: ${validResult.errors}") + } + + // Invalid format + val badFormat = """{ + "version": 1, + "builder": { + "thumbnail": {"format": "bmp"} + } + }""" + val formatResult = SettingsValidator.validate(badFormat, logWarnings = false) + if (!formatResult.hasErrors()) { + errors.add("Invalid thumbnail format 'bmp' not detected") + } + + // Invalid quality + val badQuality = """{ + "version": 1, + "builder": { + "thumbnail": {"quality": "ultra"} + } + }""" + val qualityResult = SettingsValidator.validate(badQuality, logWarnings = false) + if (!qualityResult.hasErrors()) { + errors.add("Invalid thumbnail quality 'ultra' not detected") + } + + // Invalid long_edge type + val badEdge = """{ + "version": 1, + "builder": { + "thumbnail": {"long_edge": "big"} + } + }""" + val edgeResult = SettingsValidator.validate(badEdge, logWarnings = false) + if (!edgeResult.hasErrors()) { + errors.add("Non-numeric long_edge not detected") + } + + // Invalid boolean field + val badBool = """{ + "version": 1, + "builder": { + "thumbnail": {"enabled": "yes"} + } + }""" + val boolResult = SettingsValidator.validate(badBool, logWarnings = false) + if (!boolResult.hasErrors()) { + errors.add("Non-boolean thumbnail.enabled not detected") + } + + // Unknown thumbnail key + val unknownKey = """{ + "version": 1, + "builder": { + "thumbnail": {"unknown_thumb_key": true} + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown thumbnail key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Thumbnail Section Validation", + success, + if (success) "Thumbnail section validated correctly" else "Thumbnail validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testActionsSection(): TestResult = withContext(Dispatchers.IO) { + runTest("Actions Section Validation") { + val errors = mutableListOf() + + // Valid actions with auto actions + val validActions = """{ + "version": 1, + "builder": { + "actions": { + "auto_created_action": { + "enabled": true, + "source_type": "digitalCapture" + } + } + } + }""" + val validResult = SettingsValidator.validate(validActions, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid actions section rejected: ${validResult.errors}") + } + + // Invalid source_type in auto action + val badSource = """{ + "version": 1, + "builder": { + "actions": { + "auto_opened_action": { + "source_type": "invalidType" + } + } + } + }""" + val sourceResult = SettingsValidator.validate(badSource, logWarnings = false) + if (!sourceResult.hasErrors()) { + errors.add("Invalid auto action source_type not detected") + } + + // Invalid enabled type in auto action + val badEnabled = """{ + "version": 1, + "builder": { + "actions": { + "auto_placed_action": { + "enabled": "yes" + } + } + } + }""" + val enabledResult = SettingsValidator.validate(badEnabled, logWarnings = false) + if (!enabledResult.hasErrors()) { + errors.add("Non-boolean auto action enabled not detected") + } + + // Unknown key in actions section + val unknownKey = """{ + "version": 1, + "builder": { + "actions": { + "unknown_action_key": true + } + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown action key did not produce warning") + } + + // Unknown key in auto action + val unknownAutoKey = """{ + "version": 1, + "builder": { + "actions": { + "auto_created_action": { + "unknown_auto_key": true + } + } + } + }""" + val unknownAutoResult = SettingsValidator.validate(unknownAutoKey, logWarnings = false) + if (!unknownAutoResult.hasWarnings()) { + errors.add("Unknown auto action key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Actions Section Validation", + success, + if (success) "Actions section validated correctly" else "Actions validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testLocalSigner(): TestResult = withContext(Dispatchers.IO) { + runTest("Local Signer Validation") { + val errors = mutableListOf() + + // Valid local signer + val validLocal = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY", + "tsa_url": "https://timestamp.example.com" + } + } + }""" + val validResult = SettingsValidator.validate(validLocal, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid local signer rejected: ${validResult.errors}") + } + + // Missing required fields + val missingFields = """{ + "version": 1, + "signer": { + "local": {} + } + }""" + val missingResult = SettingsValidator.validate(missingFields, logWarnings = false) + if (!missingResult.hasErrors() || missingResult.errors.size < 3) { + errors.add("Expected 3+ errors for missing local signer fields, got ${missingResult.errors.size}") + } + + // Invalid algorithm + val badAlg = """{ + "version": 1, + "signer": { + "local": { + "alg": "invalid_alg", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY" + } + } + }""" + val algResult = SettingsValidator.validate(badAlg, logWarnings = false) + if (!algResult.hasErrors()) { + errors.add("Invalid algorithm not detected") + } + + // Invalid certificate PEM + val badCert = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "not a cert", + "private_key": "$VALID_PEM_KEY" + } + } + }""" + val certResult = SettingsValidator.validate(badCert, logWarnings = false) + if (!certResult.hasErrors()) { + errors.add("Invalid certificate PEM not detected") + } + + // Invalid private key PEM + val badKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "not a key" + } + } + }""" + val keyResult = SettingsValidator.validate(badKey, logWarnings = false) + if (!keyResult.hasErrors()) { + errors.add("Invalid private key PEM not detected") + } + + // EC PRIVATE KEY format accepted + val ecKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_EC_KEY" + } + } + }""" + val ecResult = SettingsValidator.validate(ecKey, logWarnings = false) + if (ecResult.errors.any { it.contains("private_key") }) { + errors.add("EC PRIVATE KEY format rejected: ${ecResult.errors}") + } + + // RSA PRIVATE KEY format accepted + val rsaKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "ps256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_RSA_KEY" + } + } + }""" + val rsaResult = SettingsValidator.validate(rsaKey, logWarnings = false) + if (rsaResult.errors.any { it.contains("private_key") }) { + errors.add("RSA PRIVATE KEY format rejected: ${rsaResult.errors}") + } + + // Invalid TSA URL + val badTsa = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY", + "tsa_url": "ftp://not-http" + } + } + }""" + val tsaResult = SettingsValidator.validate(badTsa, logWarnings = false) + if (!tsaResult.hasErrors()) { + errors.add("Invalid TSA URL (ftp) not detected") + } + + // Unknown key in local signer + val unknownKey = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY", + "unknown_local_key": true + } + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown local signer key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Local Signer Validation", + success, + if (success) "Local signer validated correctly" else "Local signer validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testRemoteSigner(): TestResult = withContext(Dispatchers.IO) { + runTest("Remote Signer Validation") { + val errors = mutableListOf() + + // Valid remote signer + val validRemote = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com/sign", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "tsa_url": "https://timestamp.example.com" + } + } + }""" + val validResult = SettingsValidator.validate(validRemote, logWarnings = false) + if (validResult.hasErrors()) { + errors.add("Valid remote signer rejected: ${validResult.errors}") + } + + // Missing required fields + val missingFields = """{ + "version": 1, + "signer": { + "remote": {} + } + }""" + val missingResult = SettingsValidator.validate(missingFields, logWarnings = false) + if (!missingResult.hasErrors() || missingResult.errors.size < 3) { + errors.add("Expected 3+ errors for missing remote signer fields, got ${missingResult.errors.size}") + } + + // Invalid URL + val badUrl = """{ + "version": 1, + "signer": { + "remote": { + "url": "not_a_url", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT" + } + } + }""" + val urlResult = SettingsValidator.validate(badUrl, logWarnings = false) + if (!urlResult.hasErrors()) { + errors.add("Invalid URL not detected") + } + + // Invalid algorithm + val badAlg = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "invalid", + "sign_cert": "$VALID_PEM_CERT" + } + } + }""" + val algResult = SettingsValidator.validate(badAlg, logWarnings = false) + if (!algResult.hasErrors()) { + errors.add("Invalid remote algorithm not detected") + } + + // Invalid certificate PEM + val badCert = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "not a cert" + } + } + }""" + val certResult = SettingsValidator.validate(badCert, logWarnings = false) + if (!certResult.hasErrors()) { + errors.add("Invalid remote certificate PEM not detected") + } + + // Invalid TSA URL + val badTsa = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "tsa_url": "ftp://invalid" + } + } + }""" + val tsaResult = SettingsValidator.validate(badTsa, logWarnings = false) + if (!tsaResult.hasErrors()) { + errors.add("Invalid remote TSA URL not detected") + } + + // Unknown key in remote signer + val unknownKey = """{ + "version": 1, + "signer": { + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "unknown_remote_key": true + } + } + }""" + val unknownResult = SettingsValidator.validate(unknownKey, logWarnings = false) + if (!unknownResult.hasWarnings()) { + errors.add("Unknown remote signer key did not produce warning") + } + + val success = errors.isEmpty() + TestResult( + "Remote Signer Validation", + success, + if (success) "Remote signer validated correctly" else "Remote signer validation failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testSignerMutualExclusion(): TestResult = withContext(Dispatchers.IO) { + runTest("Signer Mutual Exclusion") { + val errors = mutableListOf() + + // Both local and remote + val bothSigners = """{ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY" + }, + "remote": { + "url": "https://signer.example.com", + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT" + } + } + }""" + val bothResult = SettingsValidator.validate(bothSigners, logWarnings = false) + if (!bothResult.hasErrors() || !bothResult.errors.any { it.contains("both") }) { + errors.add("Both local+remote signer not detected") + } + + // Neither local nor remote + val neitherSigner = """{ + "version": 1, + "signer": {} + }""" + val neitherResult = SettingsValidator.validate(neitherSigner, logWarnings = false) + if (!neitherResult.hasErrors() || !neitherResult.errors.any { it.contains("either") }) { + errors.add("Missing local/remote signer not detected") + } + + // cawg_x509_signer also validates + val cawgSigner = """{ + "version": 1, + "cawg_x509_signer": { + "local": { + "alg": "es256", + "sign_cert": "$VALID_PEM_CERT", + "private_key": "$VALID_PEM_KEY" + } + } + }""" + val cawgResult = SettingsValidator.validate(cawgSigner, logWarnings = false) + if (cawgResult.hasErrors()) { + errors.add("Valid cawg_x509_signer rejected: ${cawgResult.errors}") + } + + val success = errors.isEmpty() + TestResult( + "Signer Mutual Exclusion", + success, + if (success) "Signer exclusion rules validated correctly" else "Signer exclusion failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testValidationResultHelpers(): TestResult = withContext(Dispatchers.IO) { + runTest("ValidationResult Helpers") { + val errors = mutableListOf() + + // Empty result + val empty = ValidationResult() + if (empty.hasErrors()) errors.add("Empty result reports hasErrors") + if (empty.hasWarnings()) errors.add("Empty result reports hasWarnings") + if (!empty.isValid()) errors.add("Empty result reports not valid") + + // With errors + val withErrors = ValidationResult(errors = listOf("An error")) + if (!withErrors.hasErrors()) errors.add("Result with errors reports no errors") + if (withErrors.hasWarnings()) errors.add("Result with only errors reports warnings") + if (withErrors.isValid()) errors.add("Result with errors reports valid") + + // With warnings only + val withWarnings = ValidationResult(warnings = listOf("A warning")) + if (withWarnings.hasErrors()) errors.add("Result with only warnings reports errors") + if (!withWarnings.hasWarnings()) errors.add("Result with warnings reports no warnings") + if (!withWarnings.isValid()) errors.add("Result with only warnings reports not valid") + + // With both + val withBoth = ValidationResult( + errors = listOf("Error"), + warnings = listOf("Warning"), + ) + if (!withBoth.hasErrors()) errors.add("Result with both reports no errors") + if (!withBoth.hasWarnings()) errors.add("Result with both reports no warnings") + if (withBoth.isValid()) errors.add("Result with both reports valid") + + val success = errors.isEmpty() + TestResult( + "ValidationResult Helpers", + success, + if (success) "All ValidationResult helpers work correctly" else "ValidationResult helper failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testValidateAndLog(): TestResult = withContext(Dispatchers.IO) { + runTest("Validate and Log") { + // validateAndLog should work the same as validate with logWarnings=true + val result = SettingsValidator.validateAndLog("""{"version": 1}""") + val success = result.isValid() + TestResult( + "Validate and Log", + success, + if (success) "validateAndLog works correctly" else "validateAndLog failed", + "Errors: ${result.errors}, Warnings: ${result.warnings}", + ) + } + } + + suspend fun testIntentAsNumber(): TestResult = withContext(Dispatchers.IO) { + runTest("Intent As Number") { + // Intent as a number (neither string nor object) + val result = SettingsValidator.validate( + """{"version": 1, "builder": {"intent": 42}}""", + logWarnings = false, + ) + val success = result.hasErrors() && + result.errors.any { it.contains("intent") } + TestResult( + "Intent As Number", + success, + if (success) "Non-string/object intent correctly rejected" else "Expected intent type error", + "Errors: ${result.errors}", + ) + } + } +} diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt index e12ded6..4fac7fe 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt @@ -253,6 +253,151 @@ abstract class StreamTests : TestBase() { } } + suspend fun testCallbackStreamFactories(): TestResult = withContext(Dispatchers.IO) { + runTest("Callback Stream Factories") { + val errors = mutableListOf() + + // forReading factory + CallbackStream.forReading( + reader = { _, _ -> 0 }, + seeker = { _, _ -> 0L }, + ).use { stream -> + // Should support read and seek + stream.read(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + // Should throw on write + try { + stream.write(ByteArray(1), 1) + errors.add("forReading should not support write") + } catch (e: UnsupportedOperationException) { + // expected + } + // Should throw on flush + try { + stream.flush() + errors.add("forReading should not support flush") + } catch (e: UnsupportedOperationException) { + // expected + } + } + + // forWriting factory + CallbackStream.forWriting( + writer = { _, length -> length }, + seeker = { _, _ -> 0L }, + flusher = { 0 }, + ).use { stream -> + // Should support write, seek, flush + stream.write(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + stream.flush() + // Should throw on read + try { + stream.read(ByteArray(1), 1) + errors.add("forWriting should not support read") + } catch (e: UnsupportedOperationException) { + // expected + } + } + + // forReadWrite factory + CallbackStream.forReadWrite( + reader = { _, _ -> 0 }, + writer = { _, length -> length }, + seeker = { _, _ -> 0L }, + flusher = { 0 }, + ).use { stream -> + // Should support all operations + stream.read(ByteArray(1), 1) + stream.write(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + stream.flush() + } + + val success = errors.isEmpty() + TestResult( + "Callback Stream Factories", + success, + if (success) "All factory methods work correctly" else "Factory method failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testByteArrayStreamBufferGrowth(): TestResult = withContext(Dispatchers.IO) { + runTest("ByteArrayStream Buffer Growth") { + val errors = mutableListOf() + + // Start with empty stream + val stream = ByteArrayStream() + stream.use { + // Write data to trigger buffer growth + val data1 = ByteArray(100) { 0xAA.toByte() } + it.write(data1, 100) + + // Verify position and data + var result = it.getData() + if (result.size != 100) { + errors.add("After first write: expected size 100, got ${result.size}") + } + + // Write more to trigger growth + val data2 = ByteArray(200) { 0xBB.toByte() } + it.write(data2, 200) + + result = it.getData() + if (result.size != 300) { + errors.add("After second write: expected size 300, got ${result.size}") + } + + // Seek back and verify read + it.seek(0, SeekMode.START.value) + val readBuf = ByteArray(100) + val bytesRead = it.read(readBuf, 100) + if (bytesRead != 100L) { + errors.add("Read returned $bytesRead instead of 100") + } + if (readBuf[0] != 0xAA.toByte()) { + errors.add("Read data mismatch at position 0") + } + + // Seek to middle and overwrite + it.seek(50, SeekMode.START.value) + val data3 = ByteArray(10) { 0xCC.toByte() } + it.write(data3, 10) + + // Size should not change (overwrite within existing bounds) + result = it.getData() + if (result.size != 300) { + errors.add("After overwrite: expected size 300, got ${result.size}") + } + if (result[50] != 0xCC.toByte()) { + errors.add("Overwrite data mismatch at position 50") + } + + // Seek to end and verify + val endPos = it.seek(0, SeekMode.END.value) + if (endPos != 300L) { + errors.add("Seek to end returned $endPos instead of 300") + } + + // Read at end should return 0 + val endRead = it.read(ByteArray(10), 10) + if (endRead != 0L) { + errors.add("Read at end returned $endRead instead of 0") + } + } + + val success = errors.isEmpty() + TestResult( + "ByteArrayStream Buffer Growth", + success, + if (success) "Buffer growth and operations work correctly" else "Buffer growth failures", + errors.joinToString("\n"), + ) + } + } + suspend fun testLargeBufferHandling(): TestResult = withContext(Dispatchers.IO) { runTest("Large Buffer Handling") { val largeSize = Int.MAX_VALUE.toLong() + 1L diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt index 0c4a22e..e1a4a83 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -23,12 +23,22 @@ import java.io.File */ abstract class TestBase { + /** Status of an individual test execution. */ enum class TestStatus { PASSED, FAILED, SKIPPED, } + /** + * Result of a single test execution. + * + * @property name The test name. + * @property success Whether the test passed. + * @property message A human-readable summary of the outcome. + * @property details Optional additional details (e.g., stack traces, data dumps). + * @property status The test status, derived from [success] by default. + */ data class TestResult( val name: String, val success: Boolean, @@ -38,10 +48,23 @@ abstract class TestBase { ) companion object { + // Note: C2PA 2.3 spec requires first action to be "c2pa.created" or "c2pa.opened" const val TEST_MANIFEST_JSON = """{ "claim_generator": "test_app/1.0", - "assertions": [{"label": "c2pa.test", "data": {"test": true}}] + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" + } + ] + } + } + ] }""" /** Load a test resource from the classpath (test-shared module resources). */