test(marmot): run the reference implementation's own fixtures

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-09 17:43:25 +00:00
parent 9e41858c8f
commit 9e1120fc07
8 changed files with 918 additions and 0 deletions
@@ -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
}
}
@@ -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<IllegalArgumentException>("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")
}
}
}
@@ -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.
@@ -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"
]
}
]
}
@@ -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"
]
}
]
}
@@ -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."
]
}
@@ -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<V> 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."
]
}
@@ -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<V>, 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."
]
}