Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/appdata-dual-reader.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 63 additions & 13 deletions src/utils/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 & {
Expand Down Expand Up @@ -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: [] };
}
Expand All @@ -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");
}

Expand Down
91 changes: 91 additions & 0 deletions test/utils/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { deflateRawSync } from "node:zlib";

import { describe, expect, it } from "vitest";
import {
parseAppData,
Expand Down Expand Up @@ -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();
});
});
});
Loading