UNPKG

imessage-parser

Version:

Parse iMessage chat.db attributedBody NSAttributedString format in Node.js

228 lines 9.12 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.parseMessageSummary = parseMessageSummary; const typedstream_parser_1 = require("./typedstream-parser"); /** * Minimal binary plist (bplist00) parser sufficient to decode * the top-level structure of chat.db `message_summary_info` blobs. * * Reference: https://opensource.apple.com/source/CF/CF-550/CFBinaryPList.c */ function parseBplist(buf) { if (buf.length < 40) return null; const magic = buf.slice(0, 8).toString('ascii'); if (!magic.startsWith('bplist00')) return null; // The 32-byte trailer lives at the very end. const trailer = buf.slice(buf.length - 32); const offsetIntSize = trailer[6]; const objectRefSize = trailer[7]; const numObjects = Number(trailer.readBigUInt64BE(8)); const topObject = Number(trailer.readBigUInt64BE(16)); const offsetTableOffset = Number(trailer.readBigUInt64BE(24)); // Build the offset table. const offsets = []; for (let i = 0; i < numObjects; i++) { const tablePos = offsetTableOffset + i * offsetIntSize; let offset = 0; for (let j = 0; j < offsetIntSize; j++) { offset = offset * 256 + buf[tablePos + j]; } offsets.push(offset); } function readRef(pos) { let v = 0; for (let i = 0; i < objectRefSize; i++) v = (v << 8) | buf[pos + i]; return v; } function readBEInt(pos, n) { let v = 0; for (let i = 0; i < n; i++) v = v * 256 + buf[pos + i]; return v; } function parseAt(idx) { if (idx < 0 || idx >= offsets.length) return null; const pos = offsets[idx]; const marker = buf[pos]; const type = (marker & 0xf0) >> 4; const info = marker & 0x0f; switch (type) { case 0x0: // null / bool / fill if (info === 0x8) return false; if (info === 0x9) return true; return null; case 0x1: { // integer const n = 1 << info; return readBEInt(pos + 1, n); } case 0x2: { // float — not needed but return approximate if (info === 2) return buf.readFloatBE(pos + 1); if (info === 3) return buf.readDoubleBE(pos + 1); return null; } case 0x3: // date return buf.readDoubleBE(pos + 1); case 0x4: { // data (NSData) let count = info; let start = 1; if (info === 0xf) { const lenMarker = buf[pos + 1]; const lenN = 1 << (lenMarker & 0x0f); count = readBEInt(pos + 2, lenN); start = 2 + lenN; } return { _bplistData: true, data: buf.slice(pos + start, pos + start + count) }; } case 0x5: { // ASCII string let count = info; let start = 1; if (info === 0xf) { const lenMarker = buf[pos + 1]; const lenN = 1 << (lenMarker & 0x0f); count = readBEInt(pos + 2, lenN); start = 2 + lenN; } return buf.slice(pos + start, pos + start + count).toString('ascii'); } case 0x6: { // Unicode string (UTF-16 big-endian) let count = info; let start = 1; if (info === 0xf) { const lenMarker = buf[pos + 1]; const lenN = 1 << (lenMarker & 0x0f); count = readBEInt(pos + 2, lenN); start = 2 + lenN; } return buf.slice(pos + start, pos + start + count * 2).swap16().toString('utf16le'); } case 0xa: { // array let count = info; let start = 1; if (info === 0xf) { const lenMarker = buf[pos + 1]; const lenN = 1 << (lenMarker & 0x0f); count = readBEInt(pos + 2, lenN); start = 2 + lenN; } const arr = []; for (let i = 0; i < count; i++) { arr.push(parseAt(readRef(pos + start + i * objectRefSize))); } return arr; } case 0xd: { // dict let count = info; let start = 1; if (info === 0xf) { const lenMarker = buf[pos + 1]; const lenN = 1 << (lenMarker & 0x0f); count = readBEInt(pos + 2, lenN); start = 2 + lenN; } const keysStart = pos + start; const valsStart = keysStart + count * objectRefSize; const d = {}; for (let i = 0; i < count; i++) { const k = parseAt(readRef(keysStart + i * objectRefSize)); const v = parseAt(readRef(valsStart + i * objectRefSize)); if (typeof k === 'string') d[k] = v; else if (typeof k === 'number') d[String(k)] = v; } return d; } default: return null; } } return parseAt(topObject); } /** * Pick the best string from a TypedStreamParser run over an attributed-text blob. * The parser can return duplicate or near-duplicate strings (raw + cleaned); we * take the first non-empty one (single-character edits like "k" are valid). */ function extractTextFromTypedBlob(blob) { try { const parser = new typedstream_parser_1.TypedStreamParser(blob); const strings = parser.parseAllNSStrings(); for (const s of strings) { const trimmed = s.content.trim(); if (trimmed.length > 0) return trimmed; } // Fallback: extract readable text const parser2 = new typedstream_parser_1.TypedStreamParser(blob); const texts = parser2.extractReadableText(); for (const t of texts) { const trimmed = t.trim(); if (trimmed.length > 0) return trimmed; } } catch { // parsing failed — skip } return null; } /** * Decode a `message_summary_info` blob from chat.db. * * The blob is a binary plist (bplist00) that Apple's Messages app writes when * a message is edited or unsent. Its top-level keys: * - `ec` — dict of message-part-index → array of edit entries (each with * `t` = typedstream attributed-text blob, `d` = Core Data timestamp) * - `ust` — boolean, true on virtually every row * - `ams` — abbreviated summary of the current/retracted text (if present) * * @returns `editedTexts` — ordered prior revisions extracted from `ec[*][*].t` * (oldest first); empty when no edit history. * `unsent` — true when the message was retracted: no `ec` key * (no edit chain) and `ust` is true, i.e. the body * was removed entirely. */ function parseMessageSummary(buf) { if (!buf || buf.length === 0) return { editedTexts: [], unsent: false }; const plist = parseBplist(buf); if (!plist || typeof plist !== 'object') return { editedTexts: [], unsent: false }; const editedTexts = []; // `ec` is a dict keyed by part-index strings ("0", "1", …) const ec = plist['ec']; const hasEditChain = !!ec && typeof ec === 'object' && !Array.isArray(ec); if (hasEditChain) { // Iterate parts in numeric order const partKeys = Object.keys(ec).sort((a, b) => Number(a) - Number(b)); for (const partKey of partKeys) { const entries = Array.isArray(ec[partKey]) ? ec[partKey] : []; for (const entry of entries) { if (!entry || typeof entry !== 'object') continue; const t = entry['t']; if (t && t._bplistData && Buffer.isBuffer(t.data)) { const text = extractTextFromTypedBlob(t.data); if (text !== null) editedTexts.push(text); } } } } // A message is "unsent" (retracted) when there is no edit chain at all — // the sender removed the message body entirely — and `ust` is true. We key // off the *absence* of the `ec` key, not off `editedTexts.length`, so an // edited message whose text fails to decode is not misclassified as unsent. const ust = plist['ust'] === true; const unsent = ust && !hasEditChain; return { editedTexts, unsent }; } //# sourceMappingURL=message-summary-parser.js.map