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