From 1e07c137af723c3b54e29af33c17083ea3f03520 Mon Sep 17 00:00:00 2001 From: Andrew Plaza Date: Thu, 2 Jul 2026 16:14:44 -0400 Subject: [PATCH] fix(metadata): accept raw-DEFLATE appData and surface decode causes --- .changeset/appdata-dual-reader.md | 5 ++ src/utils/metadata.ts | 76 +++++++++++++++++++++----- test/utils/metadata.test.ts | 91 +++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 .changeset/appdata-dual-reader.md diff --git a/.changeset/appdata-dual-reader.md b/.changeset/appdata-dual-reader.md new file mode 100644 index 0000000..4ec8805 --- /dev/null +++ b/.changeset/appdata-dual-reader.md @@ -0,0 +1,5 @@ +--- +"@xmtp/convos-cli": patch +--- + +Accept raw-DEFLATE (iOS-compressed) appData in parseAppData/parseAppDataForWrite, and attach the original decode error as `cause` on the write-guard throw. diff --git a/src/utils/metadata.ts b/src/utils/metadata.ts index cd4cb1e..892e492 100644 --- a/src/utils/metadata.ts +++ b/src/utils/metadata.ts @@ -5,7 +5,7 @@ * Encoding: protobuf → optional DEFLATE compress → base64url */ -import { deflateSync, inflateSync } from "node:zlib"; +import { deflateSync, inflateRawSync, inflateSync } from "node:zlib"; import protobuf from "protobufjs"; const root = new protobuf.Root(); @@ -75,8 +75,24 @@ function compressIfSmaller(data: Buffer): Buffer { function decompressIfNeeded(data: Buffer): Buffer { if (data.length === 0) return data; if (data[0] === COMPRESSION_MARKER) { - // iOS format: [marker][4-byte size BE][zlib data] - const decompressed = Buffer.from(inflateSync(data.subarray(5))); + // Framed as [marker][4-byte original size BE][compressed body]. The body + // is zlib-wrapped when written by this library (Node deflateSync) but raw + // DEFLATE when written by iOS — Apple's COMPRESSION_ZLIB emits no zlib + // header despite the name. Accept both. + const body = data.subarray(5); + let decompressed: Buffer; + try { + decompressed = Buffer.from(inflateSync(body)); + } catch (zlibError) { + try { + decompressed = Buffer.from(inflateRawSync(body)); + } catch { + // Both formats failed. Surface the zlib error — it is the canonical + // format this library writes, so its failure is the meaningful one; + // the raw-deflate attempt was only the iOS-compat fallback. + throw zlibError; + } + } if (decompressed.length > MAX_DECOMPRESSED_SIZE) { throw new Error("Decompressed metadata exceeds size limit"); } @@ -111,14 +127,10 @@ function base64urlDecode(str: string): Buffer { const APP_DATA_LIMIT = 8 * 1024; // 8KB /** - * Parse appData string into ConversationCustomMetadata. - * Returns empty metadata if appData is empty or invalid. + * Strict decode: throws on undecodable appData. Internal — external callers + * use the lenient `parseAppData` or the write-guarded `parseAppDataForWrite`. */ -export function parseAppData(appData: string): ConversationCustomMetadata { - if (!appData || appData.length === 0) { - return { tag: "", profiles: [] }; - } - +function decodeAppData(appData: string): ConversationCustomMetadata { // Try legacy JSON format first (from early invite tag storage) if (appData.startsWith("{")) { try { @@ -133,8 +145,18 @@ export function parseAppData(appData: string): ConversationCustomMetadata { } } - // Try base64url → decompress → protobuf - try { + // Buffer.from(str, "base64url") never throws — invalid characters are + // silently dropped, so garbage input "decodes" to garbage bytes (often + // empty), which protobuf then happily reads as an empty message. Reject + // non-alphabet input explicitly so undecodable appData fails the decode + // (and carries a cause upstream) instead of masquerading as decoded-but- + // empty metadata. + if (!/^[A-Za-z0-9_-]+$/.test(appData)) { + throw new Error("appData is not valid base64url"); + } + + // base64url → decompress → protobuf + { const rawBytes = base64urlDecode(appData); const decompressed = decompressIfNeeded(rawBytes); const msg = ConversationCustomMetadataType.decode(decompressed) as protobuf.Message & { @@ -184,6 +206,20 @@ export function parseAppData(appData: string): ConversationCustomMetadata { encryptedGroupImage: parseEncryptedImageRef(msg.encryptedGroupImage), emoji: msg.emoji || undefined, }; + } +} + +/** + * Parse appData string into ConversationCustomMetadata. + * Returns empty metadata if appData is empty or invalid. + */ +export function parseAppData(appData: string): ConversationCustomMetadata { + if (!appData || appData.length === 0) { + return { tag: "", profiles: [] }; + } + + try { + return decodeAppData(appData); } catch { return { tag: "", profiles: [] }; } @@ -199,8 +235,22 @@ export function parseAppDataForWrite(appData: string): ConversationCustomMetadat return { tag: "", profiles: [] }; } - const parsed = parseAppData(appData); + let parsed: ConversationCustomMetadata; + try { + parsed = decodeAppData(appData); + } catch (error) { + // Undecodable bytes: writing back would clobber whatever state the group + // actually has. The original decode error rides as `cause` so callers' + // telemetry can show WHAT failed (e.g. a zlib header mismatch) instead of + // only this guard message. + throw new Error("Could not parse existing appData safely for write", { + cause: error, + }); + } if (!parsed.tag) { + // Decoded fine but tagless — still refuse (an empty tag written back + // would clobber a concurrent invite tag), with no cause: nothing failed + // to decode. throw new Error("Could not parse existing appData safely for write"); } diff --git a/test/utils/metadata.test.ts b/test/utils/metadata.test.ts index 2ed5c5b..95a5894 100644 --- a/test/utils/metadata.test.ts +++ b/test/utils/metadata.test.ts @@ -1,3 +1,5 @@ +import { deflateRawSync } from "node:zlib"; + import { describe, expect, it } from "vitest"; import { parseAppData, @@ -374,4 +376,93 @@ describe("conversation metadata", () => { expect(getProfile(meta, inboxA)).toBeUndefined(); }); }); + + describe("raw-DEFLATE (iOS) compressed appData", () => { + // iOS compresses with Apple's COMPRESSION_ZLIB, which emits raw DEFLATE + // (no zlib header). Frame: [0x1f][4-byte size BE][raw deflate body]. + function iosCompress(payload: Buffer): string { + const compressed = deflateRawSync(payload); + const sizeBytes = Buffer.alloc(4); + sizeBytes.writeUInt32BE(payload.length); + return Buffer.concat([ + Buffer.from([0x1f]), + sizeBytes, + compressed, + ]).toString("base64url"); + } + + it("parses an iOS-compressed blob identically to its uncompressed form", () => { + // A <100-byte payload is below the writer's compression threshold, so + // serializeAppData returns the raw protobuf bytes base64url-encoded — + // no un-framing needed and no coupling to the writer's compression + // heuristics. Frame those bytes the way iOS does and parse. + const uncompressed = serializeAppData({ tag: "inviteTag123", profiles: [] }); + const protobufBytes = Buffer.from(uncompressed, "base64url"); + + const iosBlob = iosCompress(protobufBytes); + const parsed = parseAppData(iosBlob); + + expect(parsed).toEqual(parseAppData(uncompressed)); + expect(parsed.tag).toBe("inviteTag123"); + }); + + it("still rejects garbage compressed bodies", () => { + const garbage = Buffer.concat([ + Buffer.from([0x1f, 0x00, 0x00, 0x00, 0x10]), + Buffer.from("definitely-not-deflate"), + ]).toString("base64url"); + expect(parseAppData(garbage)).toEqual({ tag: "", profiles: [] }); + }); + }); + + describe("parseAppDataForWrite cause threading", () => { + it("attaches the decode error as cause when appData is undecodable", () => { + const garbage = Buffer.concat([ + Buffer.from([0x1f, 0x00, 0x00, 0x00, 0x10]), + Buffer.from("definitely-not-deflate"), + ]).toString("base64url"); + let thrown: unknown; + try { + parseAppDataForWrite(garbage); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe( + "Could not parse existing appData safely for write", + ); + expect((thrown as Error).cause).toBeInstanceOf(Error); + }); + + it("attaches a cause for garbage that is not even base64url", () => { + // "!!!!": Buffer.from(.., "base64url") silently drops every char and + // yields an empty buffer, which protobuf reads as an empty message — + // without an explicit alphabet check this would misclassify as + // decoded-but-tagless (no cause). + let thrown: unknown; + try { + parseAppDataForWrite("!!!!"); + } catch (err) { + thrown = err; + } + expect((thrown as Error).cause).toBeInstanceOf(Error); + // Lenient path is unchanged: still empty metadata, no throw. + expect(parseAppData("!!!!")).toEqual({ tag: "", profiles: [] }); + }); + + it("throws WITHOUT cause when appData decodes but has an empty tag", () => { + const tagless = serializeAppData({ + tag: "", + profiles: [{ inboxId: inboxA, name: "Agent" }], + }); + let thrown: unknown; + try { + parseAppDataForWrite(tagless); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).cause).toBeUndefined(); + }); + }); });