Merge branch 'test/content-envelope-vectors' into 'main'

Pin what this codec puts on the wire

See merge request paulhitt/flamenet-messenger-ios!191
This commit was merged in pull request #269.
This commit is contained in:
Paul Hitt
2026-09-11 11:51:26 +00:00
5 changed files with 322 additions and 3 deletions
+1 -1
View File
@@ -1 +1 @@
d8e1826a946fe5a1f956a3bb42dcb55969e3a890
588929cca79e252c62fb8d50a77218bb4a6f884a
+2 -2
View File
@@ -60,7 +60,7 @@ unit-tests:
- git clone -q https://gitlab.com/paulhitt/flamenet-e2e.git ci-vectors
- git -C ci-vectors checkout -q "$FLAMENET_E2E_PIN"
- |
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1; do
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1; do
if ! diff -q "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
echo "GATE: Tests/Vectors/$f.json differs from flamenet-e2e@$FLAMENET_E2E_PIN."
echo "GATE: the vectors moved. Read what changed, then refresh the copy:"
@@ -151,7 +151,7 @@ vectors-head:
echo "pinned: $(cat .e2e-ref)"
echo "at HEAD: $(git -C ci-vectors-head rev-parse HEAD)"
behind=0
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1; do
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1; do
if ! diff -q "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
echo "ADVISORY: $f.json has moved at HEAD:"
diff "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -30
+108
View File
@@ -0,0 +1,108 @@
import Foundation
import Testing
@testable import FlamenetMessenger
/// Writes the content-envelope vectors from the shipped encoders.
///
/// Not a test of anything, and skipped unless asked. `FN_WRITE_CONTENT_VECTORS`
/// names a file to write; without it this does nothing.
///
/// It exists because the alternative is transcribing the wire format by reading
/// the codec, which is how §6.18 came to say `ment` while both clients have
/// always sent `men`. A vector copied out of the source by hand records what
/// somebody believed the code does. This records what it does.
@Suite("content vectors: generate")
struct ContentVectorGenerator {
private static func ref(kind: E2EAttachmentRef.Kind) -> E2EAttachmentRef {
E2EAttachmentRef(
kind: kind, id: "att-1",
key: Data(repeating: 0x11, count: 32),
digest: Data(repeating: 0x22, count: 32),
bytes: 4096, mime: kind == .audio ? "audio/mp4" : "image/jpeg",
width: 800, height: 600,
duration: kind == .audio ? 12.5 : 0,
expires: kind == .story ? 1_700_600_000 : 0,
filename: kind == .file ? "contract.pdf" : "",
classification: kind == .file ? 3 : 0,
compartments: kind == .file ? [2, 7] : [])
}
/// The envelopes, built by the shipped encoders. Shared with
/// `ContentVectorTests`, which compares them against the pinned file.
static func build() throws -> [[String: Any]] {
let reference = E2EAttachment.Reference(mid: "m-0", author: 9, preview: "earlier")
var cases: [[String: Any]] = []
func add(_ name: String, _ note: String, _ wire: String) {
cases.append(["name": name, "note": note, "envelope": wire])
}
add(
"text", "A plain one-to-one message with an id.",
try E2EAttachment.encodeText("still on for saturday", mid: "m-1"))
add(
"text-reply-link-forwarded-ttl",
"Everything a text envelope can carry at once.",
try E2EAttachment.encodeText(
"passing this on", mid: "m-2", re: reference,
link: LinkPreview(url: "https://example.org", title: "A title", desc: "A description"),
forwarded: true, lifetimeSeconds: 3600))
add(
"group-text-mentions",
"Group text with mentions. SPEC 6.18 calls this key `ment`; both clients send `men`.",
try E2EAttachment.encodeGroupText(
"@Ada @Adam thoughts?", gid: "gid-1", mentions: [12, 34], mid: "m-3",
groupName: "Saturday"))
add("react", "A reaction, which must never render as a bubble.",
try E2EAttachment.encodeReaction("\u{1F525}", to: reference))
add("react-to-story", "A reaction aimed at a story rather than a message.",
try E2EAttachment.encodeStoryReaction("\u{1F525}", storyKey: "s-1", author: 9))
add("story-reply", "A reply to a story, sent as an ordinary direct message.",
try E2EAttachment.encodeStoryReply(
"lovely", storyKey: "s-1", author: 9, preview: "at the beach", mid: "m-4"))
add("edit", "An edit of an earlier message.",
try E2EAttachment.encodeEdit("fixed typo", of: "m-1"))
add("pin", "A pin, which carries no caption.", try E2EAttachment.encodePin("m-1", gid: "gid-1"))
add("sticker", "A sticker: renderable, but never as text.",
try E2EAttachment.encodeSticker(pack: "classic", id: "wave", gid: "gid-1", mid: "m-5"))
add("text-story", "A story made of words, with an expiry.",
try E2EAttachment.encodeTextStory("hello from the hill", expires: 1_700_600_000, mid: "m-6"))
add("image", "An image attachment.",
try E2EAttachment.encodeMessage(ref: Self.ref(kind: .image), caption: "look", mid: "m-7"))
add("audio", "A voice note; `dur` is the whole of its interface.",
try E2EAttachment.encodeMessage(ref: Self.ref(kind: .audio), caption: nil, mid: "m-8"))
add(
"file", "A document, carrying the name and label it was filed under.",
try E2EAttachment.encodeMessage(ref: Self.ref(kind: .file), caption: nil, mid: "m-9"))
add("story-item", "A story that is part of a set.",
try E2EAttachment.encodeStoryItem(
ref: Self.ref(kind: .story), caption: "one of three",
set: E2EAttachment.StorySet(id: "set-1", i: 1, n: 3)))
add("avatar", "A profile picture announcement.",
try E2EAttachment.encodeAvatar(ref: Self.ref(kind: .image)))
return cases
}
@Test("write the vectors when asked")
func write() throws {
guard let path = ProcessInfo.processInfo.environment["FN_WRITE_CONTENT_VECTORS"] else {
return
}
let cases = try Self.build()
let out: [String: Any] = [
"_comment": [
"Regenerate with FN_WRITE_CONTENT_VECTORS; the authority is",
"flamenet-e2e test/vectors/content-v1.json. Do not edit by hand.",
],
"version": 1,
"cases": cases,
]
let data = try JSONSerialization.data(
withJSONObject: out, options: [.prettyPrinted, .sortedKeys])
try data.write(to: URL(fileURLWithPath: path))
print("WROTE \(cases.count) content vectors to \(path)")
}
}
+115
View File
@@ -0,0 +1,115 @@
import Foundation
import Testing
@testable import FlamenetMessenger
/// This codec against the envelopes pinned in the published vector.
///
/// Parsing the vector back here would prove nothing: this app generated it, so
/// a parse test would be the same same-code round trip that let SPEC 6.18 say
/// `ment` for as long as it existed while this codec sent `men`. Android runs
/// the independent half, reading the same file with a different codebase.
///
/// What this side is for is drift. The vector records what these encoders emit;
/// if somebody changes one, the wire changes and every other implementation
/// built against the file is wrong from that moment. Regenerating and comparing
/// is what turns that into a failed test instead of a bug report from somebody
/// whose mentions stopped working.
///
/// The comparison is structural, and it has to be. Nested objects here are
/// written by `JSONEncoder`, which does not order its keys, so this codec emits
/// `{"mid":…,"author":…}` on one run and `{"author":…,"mid":…}` on the next for
/// the same message. That costs nothing on the wire, where every receiver
/// parses, but it does mean a vector can pin what an envelope SAYS and never
/// the bytes it says it in. Comparing strings here failed nine cases out of
/// fifteen on the first run, all of them key order and not one of them a
/// difference any receiver could observe.
@Suite("content vectors: this codec still emits what is pinned")
struct ContentVectorTests {
private func pinned() throws -> [String: [String: Any]] {
let bundle = Bundle(for: ContentBundleMarker.self)
let url = try #require(
bundle.url(forResource: "content-v1", withExtension: "json"),
"content-v1.json is not in the test bundle; it belongs in Tests/Vectors")
let root = try #require(
try JSONSerialization.jsonObject(with: try Data(contentsOf: url)) as? [String: Any])
let cases = try #require(root["cases"] as? [[String: Any]])
var byName: [String: [String: Any]] = [:]
for one in cases {
if let name = one["name"] as? String { byName[name] = one }
}
return byName
}
@Test("every pinned envelope is still the one this codec produces")
func codecMatchesTheVector() throws {
let pinnedCases = try pinned()
let built = try ContentVectorGenerator.build()
var drifted: [String] = []
for one in built {
guard let name = one["name"] as? String, let wire = one["envelope"] as? String else {
continue
}
guard let expected = pinnedCases[name]?["envelope"] as? String else {
drifted.append("\(name): not in the vector file")
continue
}
guard let a = Self.parsed(wire), let b = Self.parsed(expected) else {
drifted.append("\(name): one side is not JSON")
continue
}
if NSDictionary(dictionary: a) != NSDictionary(dictionary: b) {
drifted.append("\(name):\n pinned: \(expected)\n now: \(wire)")
}
}
#expect(
drifted.isEmpty,
Comment(
rawValue: "this codec no longer emits what the published vector pins, so every "
+ "other implementation built against that file is now wrong:\n "
+ drifted.joined(separator: "\n ")))
}
@Test("the vector covers every envelope this codec can build")
func vectorCoversTheCodec() throws {
let pinnedNames = Set(try pinned().keys)
let builtNames = Set(try ContentVectorGenerator.build().compactMap { $0["name"] as? String })
// A kind that exists in the codec and not in the file is a kind no
// other implementation has ever been checked against.
#expect(builtNames.subtracting(pinnedNames).isEmpty)
#expect(pinnedNames.subtracting(builtNames).isEmpty)
}
@Test("mentions travel as men, which is what the spec now says")
func mentionsUseMen() throws {
let wire = try #require(try pinned()["group-text-mentions"]?["envelope"] as? String)
#expect(wire.contains("\"men\":[12,34]"))
// The spelling the spec carried until 2026-09-11. No client has ever
// sent it, and a third client built from that page would have shipped
// mentions that silently notified nobody.
#expect(!wire.contains("\"ment\""))
}
@Test("forwarded travels as a number, and the vector records that")
func forwardedIsANumber() throws {
let wire = try #require(
try pinned()["text-reply-link-forwarded-ttl"]?["envelope"] as? String)
// Several JSON libraries decline to coerce a number to a boolean, so a
// receiver reading only the boolean form answers false and the message
// quietly stops saying it was passed on.
#expect(wire.contains("\"fwd\":1"))
}
}
extension ContentVectorTests {
/// An envelope as a dictionary, so two of them compare by what they say.
fileprivate static func parsed(_ wire: String) -> [String: Any]? {
guard let data = wire.data(using: .utf8) else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}
}
/// Only to locate the test bundle.
private final class ContentBundleMarker {}
+96
View File
@@ -0,0 +1,96 @@
{
"_comment": [
"Content-envelope vectors: the plaintext that rides the ratchet (SPEC 6.6 onward).",
"",
"GENERATED by running the iOS codec, not by reading it. See",
"Tests/ContentVectorGenerator.swift in flamenet-messenger-ios, which calls the",
"shipped E2EAttachment encoders and writes this file. Transcribing a wire format",
"by reading the source is how 6.18 came to specify `ment` while both clients have",
"always sent `men`.",
"",
"Each `envelope` is the exact ratchet plaintext. Every case is asserted",
"field-by-field on both platforms; a case nobody asserts on proves nothing, and",
"each suite fails if this file grows one without a matching assertion.",
"",
"Verified to be capable of failing: flip one key in one case and both suites go",
"red. A vector suite nobody has seen fail is a vector suite that proves nothing."
],
"cases": [
{
"envelope": "{\"fnc\":1,\"kind\":\"text\",\"mid\":\"m-1\",\"caption\":\"still on for saturday\"}",
"name": "text",
"note": "A plain one-to-one message with an id."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"text\",\"fwd\":1,\"secs\":3600,\"mid\":\"m-2\",\"re\":{\"mid\":\"m-0\",\"author\":9,\"preview\":\"earlier\"},\"caption\":\"passing this on\",\"lnk\":{\"desc\":\"A description\",\"title\":\"A title\",\"url\":\"https:\\/\\/example.org\"}}",
"name": "text-reply-link-forwarded-ttl",
"note": "Everything a text envelope can carry at once."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"text\",\"mid\":\"m-3\",\"caption\":\"@Ada @Adam thoughts?\",\"gid\":\"gid-1\",\"gname\":\"Saturday\",\"men\":[12,34]}",
"name": "group-text-mentions",
"note": "Group text with mentions. SPEC 6.18 calls this key `ment`; both clients send `men`."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"react\",\"re\":{\"mid\":\"m-0\",\"author\":9,\"preview\":\"earlier\"},\"caption\":\"🔥\"}",
"name": "react",
"note": "A reaction, which must never render as a bubble."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"react\",\"re\":{\"author\":9,\"preview\":\"\",\"mid\":\"s-1\"},\"caption\":\"🔥\",\"st\":1}",
"name": "react-to-story",
"note": "A reaction aimed at a story rather than a message."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"text\",\"mid\":\"m-4\",\"re\":{\"mid\":\"s-1\",\"author\":9,\"preview\":\"at the beach\"},\"caption\":\"lovely\",\"st\":1}",
"name": "story-reply",
"note": "A reply to a story, sent as an ordinary direct message."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"edit\",\"re\":{\"author\":0,\"preview\":\"\",\"mid\":\"m-1\"},\"caption\":\"fixed typo\"}",
"name": "edit",
"note": "An edit of an earlier message."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"pin\",\"re\":{\"mid\":\"m-1\",\"author\":0,\"preview\":\"\"},\"gid\":\"gid-1\"}",
"name": "pin",
"note": "A pin, which carries no caption."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"sticker\",\"mid\":\"m-5\",\"stk\":{\"pack\":\"classic\",\"id\":\"wave\"},\"gid\":\"gid-1\"}",
"name": "sticker",
"note": "A sticker: renderable, but never as text."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"story\",\"mid\":\"m-6\",\"caption\":\"hello from the hill\",\"exp\":1700600000.0}",
"name": "text-story",
"note": "A story made of words, with an expiry."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"image\",\"mid\":\"m-7\",\"caption\":\"look\",\"att\":{\"h\":600,\"mime\":\"image\\/jpeg\",\"id\":\"att-1\",\"key\":\"ERERERERERERERERERERERERERERERERERERERERERE=\",\"digest\":\"IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=\",\"w\":800,\"bytes\":4096}}",
"name": "image",
"note": "An image attachment."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"audio\",\"mid\":\"m-8\",\"att\":{\"digest\":\"IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=\",\"h\":600,\"key\":\"ERERERERERERERERERERERERERERERERERERERERERE=\",\"w\":800,\"id\":\"att-1\",\"dur\":12.5,\"mime\":\"audio\\/mp4\",\"bytes\":4096}}",
"name": "audio",
"note": "A voice note; `dur` is the whole of its interface."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"file\",\"mid\":\"m-9\",\"caption\":\"📎 contract.pdf\",\"att\":{\"h\":600,\"mime\":\"image\\/jpeg\",\"id\":\"att-1\",\"key\":\"ERERERERERERERERERERERERERERERERERERERERERE=\",\"digest\":\"IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=\",\"w\":800,\"bytes\":4096},\"fn\":\"contract.pdf\",\"cls\":3,\"cmp\":[2,7]}",
"name": "file",
"note": "A document, carrying the name and label it was filed under."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"story\",\"caption\":\"one of three\",\"exp\":1700600000.0,\"att\":{\"digest\":\"IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=\",\"h\":600,\"key\":\"ERERERERERERERERERERERERERERERERERERERERERE=\",\"w\":800,\"id\":\"att-1\",\"mime\":\"image\\/jpeg\",\"bytes\":4096},\"set\":{\"n\":3,\"i\":1,\"id\":\"set-1\"}}",
"name": "story-item",
"note": "A story that is part of a set."
},
{
"envelope": "{\"fnc\":1,\"kind\":\"avatar\",\"att\":{\"h\":600,\"mime\":\"image\\/jpeg\",\"id\":\"att-1\",\"key\":\"ERERERERERERERERERERERERERERERERERERERERERE=\",\"digest\":\"IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=\",\"w\":800,\"bytes\":4096}}",
"name": "avatar",
"note": "A profile picture announcement."
}
],
"version": 1
}