From 9e1120fc07eb1e2699edcb09ee3aabe7a6a16b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:43:25 +0000 Subject: [PATCH] test(marmot): run the reference implementation's own fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our Marmot tests agreed with nobody but themselves. They were written from the same reading of the spec as the code they test, so a parser that read a tag differently from every other client would pass all of them. MDK ships fixtures built for exactly this. `fixtures/encrypted-media/ imeta-v2.json` says so in its own description: "Shared by marmot-app, marmot-uniffi, and wn-cli tests so every layer agrees on validation verdicts and exact wire round-trips." We are another layer and were not using it. Same for the byte-level component vectors under `cgka-conformance-simulator/vectors/byte-fixtures/`, whose manifest marks 31 of its 41 artifacts `"status": "portable"`. Copied verbatim and wired to our codecs: - **10 imeta v2 cases** — 5 golden, 5 rejections. The rejections are the half that matters: a merely lenient parser passes every golden case and still cannot be interoperated with, because it accepts tags a conformant sender never emits and then renders media another client refuses. The fixture also distinguishes an absent hint from a present-but-empty one, which is a real wire distinction we now assert rather than assume. - **10 imeta v1 cases as NEGATIVE cases.** `0x8008` is frozen and "MUST NOT be reinterpreted as v2"; the two share enough field layout that a parser keying only on fields would read one as the other and derive a file key under the wrong scheme. Every v1 case now has to bounce off the v2 parser, including the ones v1 itself calls valid. - **3 nostr-routing byte fixtures.** These are the first tests we have that pin a component's wire bytes against another implementation instead of against our own encoder — a round trip proves we can read what we wrote, which is a different and much weaker claim. The invalid fixture is the sharp one: a decoder that deduplicated the relay list rather than refusing it would hold bytes no peer agrees with. All 8 tests pass unmodified, so this is coverage rather than a fix — but it is coverage that can now fail for a reason our own tests never could. The fixtures carry a README with their provenance and a refresh command, because a copied artifact drifts silently. It also records what is still missing: the 19 portable scenario vectors need a runner that drives our client through a scripted trace and projects state per `foundation/conformance.md`, and that is where the convergence and crash/restart coverage lives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq --- .../EncryptedMediaImetaVectorTest.kt | 145 ++++++++++ .../NostrRoutingByteFixtureTest.kt | 158 +++++++++++ .../resources/marmot/conformance/README.md | 41 +++ .../marmot/conformance/imeta-v1.json | 216 +++++++++++++++ .../marmot/conformance/imeta-v2.json | 250 ++++++++++++++++++ ...routing-v1-invalid-duplicate-relay.v1.json | 36 +++ .../nostr-routing-v1-valid-state.v1.json | 38 +++ .../nostr-routing-v1-valid-update.v1.json | 34 +++ 8 files changed, 918 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/EncryptedMediaImetaVectorTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/NostrRoutingByteFixtureTest.kt create mode 100644 quartz/src/commonTest/resources/marmot/conformance/README.md create mode 100644 quartz/src/commonTest/resources/marmot/conformance/imeta-v1.json create mode 100644 quartz/src/commonTest/resources/marmot/conformance/imeta-v2.json create mode 100644 quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-invalid-duplicate-relay.v1.json create mode 100644 quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-state.v1.json create mode 100644 quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-update.v1.json diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/EncryptedMediaImetaVectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/EncryptedMediaImetaVectorTest.kt new file mode 100644 index 0000000000..792dc4714b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/EncryptedMediaImetaVectorTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.conformance + +import com.vitorpamplona.quartz.TestResourceLoader +import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 +import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2 +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The reference implementation's own `imeta` fixtures, run against our parser. + * + * These are `mdk/fixtures/encrypted-media/imeta-{v1,v2}.json`, copied verbatim. + * The file says what they are for: "Shared by marmot-app, marmot-uniffi, and + * wn-cli tests so every layer agrees on validation verdicts and exact wire + * round-trips." We are another layer, and until now we agreed with nobody but + * ourselves — our own tests would happily bless a parser that read the tag + * differently from every other client, because they were written from the same + * reading of the spec as the parser. + * + * The interesting half is the rejections. A parser that is merely lenient + * passes every golden case and still cannot be interoperated with: it accepts + * tags a conformant sender never emits, so it silently renders media that + * another client refuses, and the two disagree about what the group contains. + */ +class EncryptedMediaImetaVectorTest { + private fun fixture(name: String) = Json.parseToJsonElement(TestResourceLoader().loadString("marmot/conformance/$name")).jsonObject + + private val v2 by lazy { fixture("imeta-v2.json") } + private val v1 by lazy { fixture("imeta-v1.json") } + + private fun cases(fixture: kotlinx.serialization.json.JsonObject) = fixture["cases"]!!.jsonArray.map { it.jsonObject } + + private fun tagOf(case: kotlinx.serialization.json.JsonObject) = case["tag"]!!.jsonArray.map { it.jsonPrimitive.content }.toTypedArray() + + private fun nameOf(case: kotlinx.serialization.json.JsonObject) = case["name"]!!.jsonPrimitive.content + + @Test + fun everyGoldenV2CaseParsesToExactlyTheExpectedFields() { + val golden = cases(v2).filter { it["valid"]!!.jsonPrimitive.content == "true" } + assertTrue(golden.size >= 5, "expected the full golden set, got ${golden.size}") + + for (case in golden) { + val name = nameOf(case) + val reference = + try { + EncryptedMediaV2.parseImetaTag(tagOf(case)) + } catch (e: Exception) { + fail("golden case '$name' was rejected: ${e.message}") + } + val expected = case["expected"]!!.jsonObject + + assertEquals( + expected["locators"]!!.jsonArray.map { + it.jsonObject["kind"]!!.jsonPrimitive.content to it.jsonObject["value"]!!.jsonPrimitive.content + }, + reference.locators.map { it.kind to it.value }, + "$name locators (order is the producer's and is preserved)", + ) + assertEquals(expected["ciphertext_sha256"]!!.jsonPrimitive.content, reference.ciphertextSha256.toHexKey(), "$name ciphertext_sha256") + assertEquals(expected["plaintext_sha256"]!!.jsonPrimitive.content, reference.plaintextSha256.toHexKey(), "$name plaintext_sha256") + assertEquals(expected["nonce_hex"]!!.jsonPrimitive.content, reference.nonce.toHexKey(), "$name nonce") + assertEquals(expected["media_type"]!!.jsonPrimitive.content, reference.mediaType, "$name m") + assertEquals(expected["file_name"]!!.jsonPrimitive.content, reference.filename, "$name filename") + + // The fixture distinguishes absent (null) from present-but-empty + // (""), and so must we: a hint that was written and left blank is + // not the same wire state as one that was never written, and + // collapsing them changes what a re-encode emits. + assertEquals(optional(expected, "dim"), reference.dim, "$name dim") + assertEquals(optional(expected, "thumbhash"), reference.thumbhash, "$name thumbhash") + } + } + + @Test + fun everyRejectionV2CaseIsRejected() { + val rejections = cases(v2).filter { it["valid"]!!.jsonPrimitive.content == "false" } + assertTrue(rejections.size >= 5, "expected the full rejection set, got ${rejections.size}") + + for (case in rejections) { + val name = nameOf(case) + assertNull( + EncryptedMediaV2.parseImetaTagOrNull(tagOf(case)), + "'$name' must be rejected — the fixture expects '${case["error_contains"]?.jsonPrimitive?.content}'", + ) + } + } + + @Test + fun aV1TagIsNotReadableAsV2() { + // "Component id `0x8008` remains the frozen v1 policy and MUST NOT be + // reinterpreted as v2." The two share a field layout closely enough + // that a parser keying only on the fields would happily read one as the + // other — and then derive a file key under the wrong scheme. So every + // v1 case, including the ones v1 itself calls valid, has to bounce off + // the v2 parser. + val v1Cases = cases(v1) + assertTrue(v1Cases.isNotEmpty(), "expected the v1 fixture to carry cases") + assertEquals("encrypted-media-v1", v1["media_version"]!!.jsonPrimitive.content) + + for (case in v1Cases) { + assertNull( + EncryptedMediaV2.parseImetaTagOrNull(tagOf(case)), + "v1 case '${nameOf(case)}' must not parse as ${EncryptedMediaPolicyV2.MEDIA_FORMAT}", + ) + } + } + + /** A fixture field that is absent, JSON null, or a real string. */ + private fun optional( + obj: kotlinx.serialization.json.JsonObject, + key: String, + ): String? = + obj[key]?.let { element -> + val primitive = element.jsonPrimitive + if (primitive.isString) primitive.content else null + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/NostrRoutingByteFixtureTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/NostrRoutingByteFixtureTest.kt new file mode 100644 index 0000000000..0c9358112c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/conformance/NostrRoutingByteFixtureTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.conformance + +import com.vitorpamplona.quartz.TestResourceLoader +import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds +import com.vitorpamplona.quartz.marmot.appComponents.NostrRoutingV1 +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The reference implementation's byte-level fixtures for + * `marmot.transport.nostr.routing.v1` (`0x8004`), run against our codec. + * + * These come from `mdk/crates/cgka-conformance-simulator/vectors/byte-fixtures/`, + * copied verbatim, and they are the only tests we have that pin the WIRE BYTES + * of a Marmot app component against another implementation rather than against + * our own encoder. A round-trip test proves we can read what we wrote; these + * prove we can read what MDK wrote, which is a different claim and the one that + * matters for a group with members on both. + * + * The invalid fixture is the sharper one. Its note says implementations "MUST + * reject this update during component validation" — a decoder that quietly + * deduplicated the relay list instead would hold different canonical bytes than + * the peer that sent them, and the two would disagree about the group's state + * forever after. + */ +class NostrRoutingByteFixtureTest { + private fun fixture(name: String) = Json.parseToJsonElement(TestResourceLoader().loadString("marmot/conformance/$name")).jsonObject + + private fun bytesOf(fixture: kotlinx.serialization.json.JsonObject) = Hex.decode(fixture["bytes"]!!.jsonObject["hex"]!!.jsonPrimitive.content) + + private fun expectedRelays(fixture: kotlinx.serialization.json.JsonObject) = + fixture["expected"]!! + .jsonObject["fields"]!! + .jsonObject["relays"]!! + .jsonArray + .map { it.jsonPrimitive.content } + + private fun expectedGroupId(fixture: kotlinx.serialization.json.JsonObject) = + fixture["expected"]!! + .jsonObject["fields"]!! + .jsonObject["nostr_group_id_hex"]!! + .jsonPrimitive.content + + /** Every fixture names the component it belongs to; check we read the right ones. */ + private fun assertIsRoutingFixture(fixture: kotlinx.serialization.json.JsonObject) { + assertEquals("1", fixture["fixture_version"]!!.jsonPrimitive.content) + assertEquals( + AppComponentIds.NOSTR_ROUTING_V1, + fixture["component"]!! + .jsonObject["id"]!! + .jsonPrimitive.content + .removePrefix("0x") + .toInt(16), + ) + } + + @Test + fun theValidStateFixtureDecodesToItsDeclaredFields() { + val fixture = fixture("nostr-routing-v1-valid-state.v1.json") + assertIsRoutingFixture(fixture) + assertTrue(fixture["expected"]!!.jsonObject["valid"]!!.jsonPrimitive.content == "true") + + val decoded = NostrRoutingV1.decode(bytesOf(fixture)) + assertEquals(expectedGroupId(fixture), decoded.nostrGroupId.toHexKey()) + assertEquals(expectedRelays(fixture), decoded.relays) + } + + @Test + fun weReEncodeTheValidStateToTheSameBytes() { + // Canonical encoding is a two-way claim: reading their bytes is half of + // it, and writing bytes they would read is the other. A component whose + // re-encode differs by one byte is state a conformant decoder rejects + // outright, because it compares against its own serialization. + val fixture = fixture("nostr-routing-v1-valid-state.v1.json") + val bytes = bytesOf(fixture) + assertContentEquals(bytes, NostrRoutingV1.decode(bytes).encode()) + } + + @Test + fun theValidUpdateFixtureDecodesWithTheSameCodecAsTheState() { + // "The update is a full replacement state and begins with + // nostr_group_id[32]; it is decoded by the same codec as the state + // vector." An implementation that gave the update its own shape would + // read a relay rotation as garbage. + val fixture = fixture("nostr-routing-v1-valid-update.v1.json") + assertIsRoutingFixture(fixture) + + val bytes = bytesOf(fixture) + val decoded = NostrRoutingV1.decode(bytes) + assertEquals(expectedGroupId(fixture), decoded.nostrGroupId.toHexKey()) + assertEquals(expectedRelays(fixture), decoded.relays) + assertContentEquals(bytes, decoded.encode()) + } + + @Test + fun theDuplicateRelayFixtureIsRejected() { + val fixture = fixture("nostr-routing-v1-invalid-duplicate-relay.v1.json") + assertIsRoutingFixture(fixture) + assertTrue(fixture["expected"]!!.jsonObject["valid"]!!.jsonPrimitive.content == "false") + assertEquals( + listOf("duplicate_relay"), + fixture["expected"]!!.jsonObject["errors"]!!.jsonArray.map { it.jsonPrimitive.content }, + ) + + // Rejected, not repaired. Silently dropping the duplicate would leave + // us holding bytes no peer agrees with. + assertFailsWith("a duplicate relay must be refused, not deduplicated") { + NostrRoutingV1.decode(bytesOf(fixture)) + } + } + + @Test + fun theComponentDataWrapperCarriesTheSameStateBytes() { + // `component_data_hex` is the OpenMLS ComponentData entry: the uint16 + // component id, then the state as a variable-length vector. Checking + // the inner bytes against `hex` is what catches a framing mistake in + // the dictionary layer rather than in the component codec. + val fixture = fixture("nostr-routing-v1-valid-state.v1.json") + val wrapper = Hex.decode(fixture["bytes"]!!.jsonObject["component_data_hex"]!!.jsonPrimitive.content) + val state = bytesOf(fixture) + + val id = ((wrapper[0].toInt() and 0xff) shl 8) or (wrapper[1].toInt() and 0xff) + assertEquals(AppComponentIds.NOSTR_ROUTING_V1, id, "the wrapper names 0x8004") + if (!wrapper.copyOfRange(wrapper.size - state.size, wrapper.size).contentEquals(state)) { + fail("the ComponentData wrapper does not end with the state bytes it declares") + } + } +} diff --git a/quartz/src/commonTest/resources/marmot/conformance/README.md b/quartz/src/commonTest/resources/marmot/conformance/README.md new file mode 100644 index 0000000000..6eb58d466d --- /dev/null +++ b/quartz/src/commonTest/resources/marmot/conformance/README.md @@ -0,0 +1,41 @@ +# Marmot conformance fixtures (copied from MDK) + +These files are **copied verbatim** from the reference implementation. They are +not ours to edit: their whole value is that another implementation wrote them, +so a local "fix" to make a test pass would delete the only thing they prove. + +| File | Upstream path | +|---|---| +| `imeta-v1.json`, `imeta-v2.json` | `fixtures/encrypted-media/` | +| `nostr-routing-v1-*.v1.json` | `crates/cgka-conformance-simulator/vectors/byte-fixtures/` | + +Upstream is the MDK checkout the interop harness already vendors at +`cli/tests/marmot/state/mdk`. To refresh: + +```bash +MDK=cli/tests/marmot/state/mdk +cp $MDK/fixtures/encrypted-media/imeta-v{1,2}.json \ + quartz/src/commonTest/resources/marmot/conformance/ +cp $MDK/crates/cgka-conformance-simulator/vectors/byte-fixtures/nostr-routing-v1-*.v1.json \ + quartz/src/commonTest/resources/marmot/conformance/ +``` + +A refresh that makes a test fail is a signal, not a chore: either the wire +format moved and we have not, or upstream tightened a rule we were lenient +about. + +## What is NOT here yet + +`crates/cgka-conformance-simulator/vectors/manifest.v1.json` lists 41 artifacts, +31 marked `"status": "portable"` — built for exactly this purpose. The 19 +scenario vectors (`three-client-message-exchange`, `publish-fail`, +`convergence-*-selected`, `restart-delivery-faults`, `late-welcome-backfill`, …) +need a runner that can drive our client through a scripted trace and project +its state per `marmot/foundation/conformance.md` ("Canonical snapshot"). That +is a separate piece of work, and it is where the convergence and crash/restart +coverage lives. + +`tests/agent_text_stream_vectors.rs` pins the key context encoding, the HKDF +record-key derivation, the record AAD, the transcript hash and the broker +control envelope as literal bytes. It is Rust source rather than a data file, +so adopting it means transcribing the expected values. diff --git a/quartz/src/commonTest/resources/marmot/conformance/imeta-v1.json b/quartz/src/commonTest/resources/marmot/conformance/imeta-v1.json new file mode 100644 index 0000000000..6f0f258fb5 --- /dev/null +++ b/quartz/src/commonTest/resources/marmot/conformance/imeta-v1.json @@ -0,0 +1,216 @@ +{ + "schema": "mdk-encrypted-media-imeta-fixture/v1", + "description": "Golden and rejection imeta fixtures for encrypted-media-v1. Shared by marmot-app, marmot-uniffi, and wn-cli tests so every layer agrees on validation verdicts and exact wire round-trips. `expected.dim`/`expected.thumbhash` distinguish absent (null) from present-empty (\"\").", + "media_version": "encrypted-media-v1", + "cases": [ + { + "name": "golden-image-full", + "source_epoch": 7, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 333333333333333333333333", + "m image/png", + "filename diagram.png", + "dim 800x600", + "thumbhash 1QcSHQRnh493V4dIh4eXh1h4kJUI" + ], + "expected": { + "version": "encrypted-media-v1", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin" + } + ], + "ciphertext_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256": "2222222222222222222222222222222222222222222222222222222222222222", + "nonce_hex": "333333333333333333333333", + "media_type": "image/png", + "file_name": "diagram.png", + "dim": "800x600", + "thumbhash": "1QcSHQRnh493V4dIh4eXh1h4kJUI" + } + }, + { + "name": "golden-document-minimal", + "source_epoch": 42, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/4444444444444444444444444444444444444444444444444444444444444444.bin", + "ciphertext_sha256 4444444444444444444444444444444444444444444444444444444444444444", + "plaintext_sha256 5555555555555555555555555555555555555555555555555555555555555555", + "nonce 666666666666666666666666", + "m application/pdf", + "filename brief.pdf" + ], + "expected": { + "version": "encrypted-media-v1", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/4444444444444444444444444444444444444444444444444444444444444444.bin" + } + ], + "ciphertext_sha256": "4444444444444444444444444444444444444444444444444444444444444444", + "plaintext_sha256": "5555555555555555555555555555555555555555555555555555555555555555", + "nonce_hex": "666666666666666666666666", + "media_type": "application/pdf", + "file_name": "brief.pdf", + "dim": null, + "thumbhash": null + } + }, + { + "name": "golden-present-empty-dim", + "source_epoch": 7, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 333333333333333333333333", + "m image/png", + "filename diagram.png", + "dim " + ], + "expected": { + "version": "encrypted-media-v1", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin" + } + ], + "ciphertext_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256": "2222222222222222222222222222222222222222222222222222222222222222", + "nonce_hex": "333333333333333333333333", + "media_type": "image/png", + "file_name": "diagram.png", + "dim": "", + "thumbhash": null + } + }, + { + "name": "rejects-missing-nonce", + "source_epoch": 7, + "valid": false, + "error_contains": "nonce", + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "m image/png", + "filename diagram.png" + ] + }, + { + "name": "rejects-duplicate-media-type", + "source_epoch": 7, + "valid": false, + "error_contains": "exactly one m", + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 333333333333333333333333", + "m image/png", + "filename diagram.png", + "m image/jpeg" + ] + }, + { + "name": "rejects-blurhash-field", + "source_epoch": 7, + "valid": false, + "error_contains": "thumbhash", + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 333333333333333333333333", + "m image/png", + "filename diagram.png", + "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj" + ] + }, + { + "name": "rejects-blossom-locator-hash-mismatch", + "source_epoch": 7, + "valid": false, + "error_contains": "locator", + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/7777777777777777777777777777777777777777777777777777777777777777.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 333333333333333333333333", + "m image/png", + "filename diagram.png" + ] + }, + { + "name": "rejects-unknown-version", + "source_epoch": 7, + "valid": false, + "error_contains": "version", + "tag": [ + "imeta", + "v encrypted-media-v3", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 333333333333333333333333", + "m image/png", + "filename diagram.png" + ] + }, + { + "name": "rejects-whitespace-only-filename", + "source_epoch": 7, + "valid": false, + "error_contains": "file name", + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 333333333333333333333333", + "m image/png", + "filename " + ] + }, + { + "name": "rejects-short-nonce", + "source_epoch": 7, + "valid": false, + "error_contains": "nonce", + "tag": [ + "imeta", + "v encrypted-media-v1", + "locator blossom-v1 https://media.example/1111111111111111111111111111111111111111111111111111111111111111.bin", + "ciphertext_sha256 1111111111111111111111111111111111111111111111111111111111111111", + "plaintext_sha256 2222222222222222222222222222222222222222222222222222222222222222", + "nonce 3333333333333333", + "m image/png", + "filename diagram.png" + ] + } + ] +} diff --git a/quartz/src/commonTest/resources/marmot/conformance/imeta-v2.json b/quartz/src/commonTest/resources/marmot/conformance/imeta-v2.json new file mode 100644 index 0000000000..4d180aba3a --- /dev/null +++ b/quartz/src/commonTest/resources/marmot/conformance/imeta-v2.json @@ -0,0 +1,250 @@ +{ + "schema": "mdk-encrypted-media-imeta-fixture/v1", + "description": "Golden and rejection imeta fixtures for encrypted-media-v2. Shared by marmot-app, marmot-uniffi, and wn-cli tests so every layer agrees on validation verdicts and exact wire round-trips. `expected.dim`/`expected.thumbhash` distinguish absent (null) from present-empty (\"\").", + "media_version": "encrypted-media-v2", + "cases": [ + { + "name": "golden-image-full", + "source_epoch": 9, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m image/jpeg", + "filename photo.jpg", + "dim 1920x1080", + "thumbhash 1QcSHQRnh493V4dIh4eXh1h4kJUI" + ], + "expected": { + "version": "encrypted-media-v2", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/abababababababababababababababababababababababababababababababab.bin" + } + ], + "ciphertext_sha256": "abababababababababababababababababababababababababababababababab", + "plaintext_sha256": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce_hex": "efefefefefefefefefefefef", + "media_type": "image/jpeg", + "file_name": "photo.jpg", + "dim": "1920x1080", + "thumbhash": "1QcSHQRnh493V4dIh4eXh1h4kJUI" + } + }, + { + "name": "golden-audio-minimal", + "source_epoch": 3, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/1212121212121212121212121212121212121212121212121212121212121212.bin", + "ciphertext_sha256 1212121212121212121212121212121212121212121212121212121212121212", + "plaintext_sha256 3434343434343434343434343434343434343434343434343434343434343434", + "nonce 565656565656565656565656", + "m audio/ogg", + "filename voice.ogg" + ], + "expected": { + "version": "encrypted-media-v2", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/1212121212121212121212121212121212121212121212121212121212121212.bin" + } + ], + "ciphertext_sha256": "1212121212121212121212121212121212121212121212121212121212121212", + "plaintext_sha256": "3434343434343434343434343434343434343434343434343434343434343434", + "nonce_hex": "565656565656565656565656", + "media_type": "audio/ogg", + "file_name": "voice.ogg", + "dim": null, + "thumbhash": null + } + }, + { + "name": "golden-multi-locator", + "source_epoch": 12, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "locator mirror-v9 https://mirror.example/blob", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m video/mp4", + "filename clip.mp4" + ], + "expected": { + "version": "encrypted-media-v2", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/abababababababababababababababababababababababababababababababab.bin" + }, + { + "kind": "mirror-v9", + "value": "https://mirror.example/blob" + } + ], + "ciphertext_sha256": "abababababababababababababababababababababababababababababababab", + "plaintext_sha256": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce_hex": "efefefefefefefefefefefef", + "media_type": "video/mp4", + "file_name": "clip.mp4", + "dim": null, + "thumbhash": null + } + }, + { + "name": "golden-present-empty-dim", + "source_epoch": 5, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m image/jpeg", + "filename photo.jpg", + "dim " + ], + "expected": { + "version": "encrypted-media-v2", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/abababababababababababababababababababababababababababababababab.bin" + } + ], + "ciphertext_sha256": "abababababababababababababababababababababababababababababababab", + "plaintext_sha256": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce_hex": "efefefefefefefefefefefef", + "media_type": "image/jpeg", + "file_name": "photo.jpg", + "dim": "", + "thumbhash": null + } + }, + { + "name": "golden-exact-space-filename", + "source_epoch": 5, + "valid": true, + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m image/jpeg", + "filename " + ], + "expected": { + "version": "encrypted-media-v2", + "locators": [ + { + "kind": "blossom-v1", + "value": "https://media.example/abababababababababababababababababababababababababababababababab.bin" + } + ], + "ciphertext_sha256": "abababababababababababababababababababababababababababababababab", + "plaintext_sha256": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce_hex": "efefefefefefefefefefefef", + "media_type": "image/jpeg", + "file_name": " ", + "dim": null, + "thumbhash": null + } + }, + { + "name": "rejects-noncanonical-media-type", + "source_epoch": 9, + "valid": false, + "error_contains": "canonical", + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m Image/JPG", + "filename photo.jpg" + ] + }, + { + "name": "rejects-overlong-filename", + "source_epoch": 9, + "valid": false, + "error_contains": "file name", + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m image/jpeg", + "filename aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + }, + { + "name": "rejects-nul-in-filename", + "source_epoch": 9, + "valid": false, + "error_contains": "file name", + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m image/jpeg", + "filename bad\u0000name.jpg" + ] + }, + { + "name": "rejects-duplicate-plaintext-hash", + "source_epoch": 9, + "valid": false, + "error_contains": "exactly one plaintext_sha256", + "tag": [ + "imeta", + "v encrypted-media-v2", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m image/jpeg", + "filename photo.jpg", + "plaintext_sha256 3434343434343434343434343434343434343434343434343434343434343434" + ] + }, + { + "name": "rejects-missing-version", + "source_epoch": 9, + "valid": false, + "error_contains": "missing v", + "tag": [ + "imeta", + "locator blossom-v1 https://media.example/abababababababababababababababababababababababababababababababab.bin", + "ciphertext_sha256 abababababababababababababababababababababababababababababababab", + "plaintext_sha256 cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "nonce efefefefefefefefefefefef", + "m image/jpeg", + "filename photo.jpg" + ] + } + ] +} diff --git a/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-invalid-duplicate-relay.v1.json b/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-invalid-duplicate-relay.v1.json new file mode 100644 index 0000000000..68be8741b7 --- /dev/null +++ b/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-invalid-duplicate-relay.v1.json @@ -0,0 +1,36 @@ +{ + "fixture_name": "marmot.transport.nostr.routing.v1/update-invalid-duplicate-relay/v1", + "fixture_version": "1", + "vector_type": "app_component_update", + "component": { + "id": "0x8004", + "name": "marmot.transport.nostr.routing.v1", + "document": "spec/app-components/nostr-routing-v1.md" + }, + "encoding": { + "format": "Marmot canonical encoding", + "notes": [ + "bytes.hex is the MarmotNostrRoutingUpdateV1 payload (full replacement state), not the AppDataUpdate proposal wrapper.", + "It carries nostr_group_id[32] then two relay entries that decode to the same byte string." + ] + }, + "bytes": { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2c157773733a2f2f72656c61792d612e6578616d706c65157773733a2f2f72656c61792d612e6578616d706c65" + }, + "expected": { + "valid": false, + "fields": { + "nostr_group_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "relays": [ + "wss://relay-a.example", + "wss://relay-a.example" + ] + }, + "errors": [ + "duplicate_relay" + ] + }, + "notes": [ + "Implementations MUST reject this update during component validation; the reference decoder returns a duplicate-relay error." + ] +} diff --git a/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-state.v1.json b/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-state.v1.json new file mode 100644 index 0000000000..d2a7d9e14e --- /dev/null +++ b/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-state.v1.json @@ -0,0 +1,38 @@ +{ + "fixture_name": "marmot.transport.nostr.routing.v1/state-valid-two-relays/v1", + "fixture_version": "1", + "vector_type": "app_component_state", + "component": { + "id": "0x8004", + "name": "marmot.transport.nostr.routing.v1", + "document": "spec/app-components/nostr-routing-v1.md" + }, + "encoding": { + "format": "Marmot canonical encoding", + "notes": [ + "bytes.hex is the component state bytes stored in AppDataDictionary.component_data.data, in the Marmot canonical encoding (spec/foundation/canonical-encoding.md): QUIC variable-length length prefixes.", + "opaque nostr_group_id[32] is exactly 32 bytes with NO length prefix.", + "relays is a QUIC-varint byte length, then each MarmotNostrRelayV1.url<1..512> is a QUIC-varint length prefix followed by the URL bytes.", + "bytes.component_data_hex is the OpenMLS ComponentData entry: component_id (uint16 0x8004) then the data as a variable-length byte vector wrapping bytes.hex." + ] + }, + "bytes": { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2c157773733a2f2f72656c61792d612e6578616d706c65157773733a2f2f72656c61792d622e6578616d706c65", + "component_data_hex": "8004404d000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2c157773733a2f2f72656c61792d612e6578616d706c65157773733a2f2f72656c61792d622e6578616d706c65" + }, + "expected": { + "valid": true, + "fields": { + "nostr_group_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "relays": [ + "wss://relay-a.example", + "wss://relay-b.example" + ] + }, + "errors": [] + }, + "notes": [ + "Relay order is byte-lexicographic over the URL content bytes and duplicate-free.", + "This fixture does not include an AppDataUpdate wrapper." + ] +} diff --git a/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-update.v1.json b/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-update.v1.json new file mode 100644 index 0000000000..f0c4b5b9ca --- /dev/null +++ b/quartz/src/commonTest/resources/marmot/conformance/nostr-routing-v1-valid-update.v1.json @@ -0,0 +1,34 @@ +{ + "fixture_name": "marmot.transport.nostr.routing.v1/update-valid-full-replacement/v1", + "fixture_version": "1", + "vector_type": "app_component_update", + "component": { + "id": "0x8004", + "name": "marmot.transport.nostr.routing.v1", + "document": "spec/app-components/nostr-routing-v1.md" + }, + "encoding": { + "format": "Marmot canonical encoding", + "notes": [ + "bytes.hex is the MarmotNostrRoutingUpdateV1 payload, not the AppDataUpdate proposal wrapper.", + "The update payload is a full replacement state (MarmotNostrRoutingUpdateV1 = MarmotNostrRoutingV1): it carries the 32-byte nostr_group_id followed by the new relays, in the Marmot canonical encoding.", + "This example changes the relay list to a single relay while keeping nostr_group_id; an update MAY also change nostr_group_id (a routing rotation)." + ] + }, + "bytes": { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f16157773733a2f2f72656c61792d632e6578616d706c65" + }, + "expected": { + "valid": true, + "fields": { + "nostr_group_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "relays": [ + "wss://relay-c.example" + ] + }, + "errors": [] + }, + "notes": [ + "The update is a full replacement state and begins with nostr_group_id[32]; it is decoded by the same codec as the state vector." + ] +}