2023-10-30 16:24:28 +00:00
|
|
|
// Copyright 2015 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
|
|
|
import { assert } from 'chai';
|
2024-07-11 19:44:09 +00:00
|
|
|
import { createReadStream, unlinkSync, writeFileSync } from 'fs';
|
2023-10-30 16:24:28 +00:00
|
|
|
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';
|
2024-07-11 19:44:09 +00:00
|
|
|
import { APPLICATION_OCTET_STREAM } from '../types/MIME';
|
2023-10-30 16:24:28 +00:00
|
|
|
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';
|
2024-07-11 19:44:09 +00:00
|
|
|
import { generateKeys, encryptAttachmentV2ToDisk } from '../AttachmentCrypto';
|
2023-10-30 16:24:28 +00:00
|
|
|
|
|
|
|
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 () => {
|
2024-07-11 19:44:09 +00:00
|
|
|
let path: string | undefined;
|
2023-10-30 16:24:28 +00:00
|
|
|
|
|
|
|
try {
|
2024-07-11 19:44:09 +00:00
|
|
|
const data = getTestBuffer();
|
|
|
|
const keys = generateKeys();
|
|
|
|
|
|
|
|
({ path } = await encryptAttachmentV2ToDisk({
|
|
|
|
plaintext: { data },
|
|
|
|
keys,
|
|
|
|
getAbsoluteAttachmentPath:
|
|
|
|
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
|
|
|
}));
|
|
|
|
|
|
|
|
const contacts = await parseContactsV2({
|
|
|
|
version: 2,
|
|
|
|
localKey: Buffer.from(keys).toString('base64'),
|
|
|
|
path,
|
|
|
|
size: data.byteLength,
|
|
|
|
contentType: APPLICATION_OCTET_STREAM,
|
|
|
|
});
|
2023-10-30 16:24:28 +00:00
|
|
|
|
|
|
|
assert.strictEqual(contacts.length, 3);
|
|
|
|
|
2024-07-11 19:44:09 +00:00
|
|
|
await Promise.all(contacts.map(contact => verifyContact(contact)));
|
2023-10-30 16:24:28 +00:00
|
|
|
} finally {
|
2024-07-11 19:44:09 +00:00
|
|
|
if (path) {
|
|
|
|
await window.Signal.Migrations.deleteAttachmentData(path);
|
2023-10-30 16:24:28 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
2024-07-11 19:44:09 +00:00
|
|
|
await Promise.all(contacts.map(contact => verifyContact(contact)));
|
2023-10-30 16:24:28 +00:00
|
|
|
} 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);
|
|
|
|
|
2024-07-11 19:44:09 +00:00
|
|
|
await Promise.all(
|
|
|
|
contacts.map((contact, index) => {
|
|
|
|
const avatarIsMissing = index === 0;
|
|
|
|
return verifyContact(contact, avatarIsMissing);
|
|
|
|
})
|
|
|
|
);
|
2023-10-30 16:24:28 +00:00
|
|
|
} 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);
|
|
|
|
|
2024-07-11 19:44:09 +00:00
|
|
|
await Promise.all(contacts.map(contact => verifyContact(contact)));
|
2023-10-30 16:24:28 +00:00
|
|
|
} 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<Uint8Array> = [];
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-07-11 19:44:09 +00:00
|
|
|
async function verifyContact(
|
2023-10-30 16:24:28 +00:00
|
|
|
contact: ContactDetailsWithAvatar,
|
|
|
|
avatarIsMissing?: boolean
|
2024-07-11 19:44:09 +00:00
|
|
|
): Promise<void> {
|
2023-10-30 16:24:28 +00:00
|
|
|
assert.strictEqual(contact.name, 'Zero Cool');
|
|
|
|
assert.strictEqual(contact.number, '+10000000000');
|
|
|
|
assert.strictEqual(contact.aci, '7198e1bd-1293-452a-a098-f982ff201902');
|
|
|
|
|
|
|
|
if (avatarIsMissing) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-07-11 19:44:09 +00:00
|
|
|
strictAssert(contact.avatar?.path, 'Avatar needs path');
|
2023-10-30 16:24:28 +00:00
|
|
|
|
2024-07-11 19:44:09 +00:00
|
|
|
const avatarBytes = await window.Signal.Migrations.readAttachmentData(
|
|
|
|
contact.avatar
|
|
|
|
);
|
|
|
|
await window.Signal.Migrations.deleteAttachmentData(contact.avatar.path);
|
2023-10-30 16:24:28 +00:00
|
|
|
|
|
|
|
for (let j = 0; j < 255; j += 1) {
|
|
|
|
assert.strictEqual(avatarBytes[j], j);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function parseContactsWithSmallChunkSize({
|
|
|
|
absolutePath,
|
|
|
|
}: {
|
|
|
|
absolutePath: string;
|
|
|
|
}): Promise<ReadonlyArray<ContactDetailsWithAvatar>> {
|
|
|
|
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;
|
|
|
|
}
|