diff --git a/.e2e-ref b/.e2e-ref index f1b77fb..ed4057d 100644 --- a/.e2e-ref +++ b/.e2e-ref @@ -1 +1 @@ -32d3a8824b06e53a0204feebedfee6dd9d384e7b +d8e1826a946fe5a1f956a3bb42dcb55969e3a890 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4a78e22..d1a69e6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -61,12 +61,13 @@ unit-tests: - export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/pqx3dh-v2.json" - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json" + - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json" # Checked before the suite runs. A path that silently does not exist puts # the tests straight back where they were, and the failure would read as a # protocol bug rather than a missing file. - | for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \ - "$FLAMENET_BACKUP_VECTORS"; do + "$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS"; do [ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; } done # Clear old results before running, or the gate counts ghosts. The @@ -158,6 +159,7 @@ vectors-head: - export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/pqx3dh-v2.json" - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json" + - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json" # Only the module that reads vectors. The rest of the suite has nothing # to say about whether flamenet-e2e moved, and running it here would double # the board's cost to answer a question it cannot answer. diff --git a/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt b/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt index fc1cfc0..3c51f9b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt @@ -143,10 +143,11 @@ object BackupPayload { .put("at", m.optLong("at")) .put("text", m.optString("text")) m.optString("mid").takeIf { it.isNotEmpty() }?.let { out.put("mid", it) } - // The author rides along on one-to-one threads too. The iPhone reads it - // only for groups and ignores it elsewhere, which costs nothing and - // keeps an Android-to-Android restore whole. - if (m.has("author")) out.put("author", m.optLong("author")) + // The author rides along on group threads, where it says who spoke. + // Only when there is one: this app writes 0 on a one-to-one message, + // where the author is whoever is not you, and emitting that would put + // a claim in the file that the message came from user zero. + m.optLong("author", 0L).takeIf { it != 0L }?.let { out.put("author", it) } m.optJSONObject("att")?.let { out.put("att", attachmentToShared(it, m.optString("kind", Attachment.IMAGE))) } @@ -307,7 +308,9 @@ object BackupPayload { .put("mine", m.optBoolean("mine")) .put("at", m.optLong("at")) .put("text", m.optString("text")) - .put("author", m.optLong("author")) + // Storage keeps an author on every message and reads a missing one as + // 0, so this is written only when the file carried one. + m.optLong("author", 0L).takeIf { it != 0L }?.let { out.put("author", it) } m.optString("mid").takeIf { it.isNotEmpty() }?.let { out.put("mid", it) } m.optJSONObject("att")?.let { att -> out.put("kind", att.optString("kind", Attachment.IMAGE)) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BackupPayloadVectorTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BackupPayloadVectorTest.kt new file mode 100644 index 0000000..29889d5 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/BackupPayloadVectorTest.kt @@ -0,0 +1,109 @@ +package io.flamenet.e2e + +import org.json.JSONArray +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import java.io.File as JFile + +/** + * The payload inside a §16 backup, against the published vector. + * + * §16 pinned the envelope and nothing else, so both apps sealed their own + * storage and refused each other's files. Both now claim one shape, and until + * this vector existed nothing checked that claim: each side's tests asserted + * against what the OTHER was believed to write, transcribed by hand from + * reading its source. Agreeing with your own reading of somebody else's code is + * not interoperability. + * + * The bar here is exact. Decode the canonical payload, encode it again, and the + * result must match byte for byte after both are put in a canonical order. + * Anything looser passes while a field quietly goes missing, which is the whole + * failure being guarded against. + */ +class BackupPayloadVectorTest { + private val vectors: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_BACKUP_PAYLOAD_VECTORS"), + "../../engine/test/vectors/backup-payload-v1.json", + "../../../flamenet-e2e/test/vectors/backup-payload-v1.json", + "../../flamenet-e2e/test/vectors/backup-payload-v1.json", + ).map { JFile(it) } + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError( + "backup payload vectors not found. Looked in: " + + candidates.joinToString(", ") { it.absolutePath }, + ) + JSONObject(found.readText()) + } + + /** Sorted keys, so comparing two payloads compares content and not order. */ + private fun canonical(value: Any?): String = + when (value) { + is JSONObject -> { + value + .keys() + .asSequence() + .sorted() + .joinToString(",", "{", "}") { k -> JSONObject.quote(k) + ":" + canonical(value.get(k)) } + } + + is JSONArray -> { + (0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) } + } + + // A whole number that arrives as a Double must not print as + // "3600.0" on one side and "3600" on the other; the difference is + // an artefact of the parser, not of the file. + is Double -> { + if (value == Math.floor(value) && !value.isInfinite()) value.toLong().toString() else value.toString() + } + + is Number -> { + value.toString() + } + + is String -> { + JSONObject.quote(value) + } + + else -> { + value.toString() + } + } + + @Test + fun `the canonical payload survives this app unchanged`() { + val pinned = vectors.getJSONObject("canonical") + val here = BackupPayload.toShared(BackupPayload.fromShared(pinned)) + assertEquals( + canonical(pinned), + canonical(here), + "this app changed the shared payload on a round trip, so a backup written here " + + "is not the one an iPhone reads", + ) + } + + @Test + fun `the vector says what only one side keeps`() { + // A field missing from a vector looks exactly like a field nobody + // thought of. Naming them is what makes a gap a decision. + val listed = vectors.getJSONArray("divergences") + assertTrue(listed.length() > 0) + for (i in 0 until listed.length()) { + val d = listed.getJSONObject(i) + assertTrue(d.getString("field").isNotEmpty()) + assertTrue(d.getString("detail").length > 40, "a divergence without its consequence is a note") + } + } + + @Test + fun `the outbox is not in the vector, and must not be added`() { + // Both apps flush it every poll cycle, so a restored outbox sends at + // once; a backup opened months later would deliver forgotten messages. + assertTrue(!vectors.getJSONObject("canonical").has("outbox")) + } +}