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:
@@ -1 +1 @@
|
||||
d8e1826a946fe5a1f956a3bb42dcb55969e3a890
|
||||
588929cca79e252c62fb8d50a77218bb4a6f884a
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user