// Copyright 2015 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; import { createReadStream, unlinkSync, writeFileSync } from 'fs'; import { v4 as generateGuid } from 'uuid'; import { join } from 'path'; import { pipeline } from 'stream/promises'; import { Transform } from 'stream'; import protobuf from '../protobuf/wrap'; import * as log from '../logging/log'; import * as Bytes from '../Bytes'; import * as Errors from '../types/errors'; import { APPLICATION_OCTET_STREAM } from '../types/MIME'; import { SignalService as Proto } from '../protobuf'; import { ParseContactsTransform, parseContactsV2, } from '../textsecure/ContactsParser'; import type { ContactDetailsWithAvatar } from '../textsecure/ContactsParser'; import { createTempDir, deleteTempDir } from '../updater/common'; import { strictAssert } from '../util/assert'; import { generateKeys, encryptAttachmentV2ToDisk } from '../AttachmentCrypto'; const { Writer } = protobuf; describe('ContactsParser', () => { let tempDir: string; beforeEach(async () => { tempDir = await createTempDir(); }); afterEach(async () => { await deleteTempDir(log, tempDir); }); describe('parseContactsV2', () => { it('parses an array buffer of contacts', async () => { let path: string | undefined; try { const data = getTestBuffer(); const keys = generateKeys(); ({ path } = await encryptAttachmentV2ToDisk({ keys, getAbsoluteAttachmentPath: window.Signal.Migrations.getAbsoluteAttachmentPath, needIncrementalMac: false, plaintext: { data }, })); const contacts = await parseContactsV2({ version: 2, localKey: Buffer.from(keys).toString('base64'), path, size: data.byteLength, contentType: APPLICATION_OCTET_STREAM, }); assert.strictEqual(contacts.length, 3); await Promise.all(contacts.map(contact => verifyContact(contact))); } finally { if (path) { await window.Signal.Migrations.deleteAttachmentData(path); } } }); it('parses an array buffer of contacts with small chunk size', async () => { let absolutePath: string | undefined; try { const bytes = getTestBuffer(); const fileName = generateGuid(); absolutePath = join(tempDir, fileName); writeFileSync(absolutePath, bytes); const contacts = await parseContactsWithSmallChunkSize({ absolutePath, }); assert.strictEqual(contacts.length, 3); await Promise.all(contacts.map(contact => verifyContact(contact))); } finally { if (absolutePath) { unlinkSync(absolutePath); } } }); it('parses an array buffer of contacts where one contact has no avatar', async () => { let absolutePath: string | undefined; try { const bytes = Bytes.concatenate([ generatePrefixedContact(undefined), getTestBuffer(), ]); const fileName = generateGuid(); absolutePath = join(tempDir, fileName); writeFileSync(absolutePath, bytes); const contacts = await parseContactsWithSmallChunkSize({ absolutePath, }); assert.strictEqual(contacts.length, 4); await Promise.all( contacts.map((contact, index) => { const avatarIsMissing = index === 0; return verifyContact(contact, avatarIsMissing); }) ); } finally { if (absolutePath) { unlinkSync(absolutePath); } } }); it('parses an array buffer of contacts where contacts are dropped due to missing ACI', async () => { let absolutePath: string | undefined; try { const avatarBuffer = generateAvatar(); const bytes = Bytes.concatenate([ generatePrefixedContact(avatarBuffer, 'invalid'), avatarBuffer, generatePrefixedContact(undefined, 'invalid'), getTestBuffer(), ]); const fileName = generateGuid(); absolutePath = join(tempDir, fileName); writeFileSync(absolutePath, bytes); const contacts = await parseContactsWithSmallChunkSize({ absolutePath, }); assert.strictEqual(contacts.length, 3); await Promise.all(contacts.map(contact => verifyContact(contact))); } finally { if (absolutePath) { unlinkSync(absolutePath); } } }); }); }); class SmallChunksTransform extends Transform { constructor(private chunkSize: number) { super(); } override _transform( incomingChunk: Buffer | undefined, _encoding: string, done: (error?: Error) => void ) { if (!incomingChunk || incomingChunk.byteLength === 0) { done(); return; } try { const totalSize = incomingChunk.byteLength; const chunkCount = Math.floor(totalSize / this.chunkSize); const remainder = totalSize % this.chunkSize; for (let i = 0; i < chunkCount; i += 1) { const start = i * this.chunkSize; const end = start + this.chunkSize; this.push(incomingChunk.subarray(start, end)); } if (remainder > 0) { this.push(incomingChunk.subarray(chunkCount * this.chunkSize)); } } catch (error) { done(error); return; } done(); } } function generateAvatar(): Uint8Array { const result = new Uint8Array(255); for (let i = 0; i < result.length; i += 1) { result[i] = i; } return result; } function getTestBuffer(): Uint8Array { const avatarBuffer = generateAvatar(); const prefixedContact = generatePrefixedContact(avatarBuffer); const chunks: Array = []; for (let i = 0; i < 3; i += 1) { chunks.push(prefixedContact); chunks.push(avatarBuffer); } return Bytes.concatenate(chunks); } function generatePrefixedContact( avatarBuffer: Uint8Array | undefined, aci = '7198E1BD-1293-452A-A098-F982FF201902' ) { const contactInfoBuffer = Proto.ContactDetails.encode({ name: 'Zero Cool', number: '+10000000000', aci, avatar: avatarBuffer ? { contentType: 'image/jpeg', length: avatarBuffer.length } : undefined, }).finish(); const writer = new Writer(); writer.bytes(contactInfoBuffer); const prefixedContact = writer.finish(); return prefixedContact; } async function verifyContact( contact: ContactDetailsWithAvatar, avatarIsMissing?: boolean ): Promise { assert.strictEqual(contact.name, 'Zero Cool'); assert.strictEqual(contact.number, '+10000000000'); assert.strictEqual(contact.aci, '7198e1bd-1293-452a-a098-f982ff201902'); if (avatarIsMissing) { return; } strictAssert(contact.avatar?.path, 'Avatar needs path'); const avatarBytes = await window.Signal.Migrations.readAttachmentData( contact.avatar ); await window.Signal.Migrations.deleteAttachmentData(contact.avatar.path); for (let j = 0; j < 255; j += 1) { assert.strictEqual(avatarBytes[j], j); } } async function parseContactsWithSmallChunkSize({ absolutePath, }: { absolutePath: string; }): Promise> { const logId = 'parseContactsWithSmallChunkSize'; const readStream = createReadStream(absolutePath); const smallChunksTransform = new SmallChunksTransform(32); const parseContactsTransform = new ParseContactsTransform(); try { await pipeline(readStream, smallChunksTransform, parseContactsTransform); } catch (error) { try { readStream.close(); } catch (cleanupError) { log.error( `${logId}: Failed to clean up after error`, Errors.toLogFormat(cleanupError) ); } throw error; } readStream.close(); return parseContactsTransform.contacts; }