Add an assertion when updating conversations; update cleanData

This commit is contained in:
Evan Hahn 2021-02-04 13:54:03 -06:00 committed by GitHub
parent 73a304faba
commit bc37b5c907
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 749 additions and 79 deletions

View File

@ -6,12 +6,18 @@
const { ipcRenderer } = require('electron');
const url = require('url');
const i18n = require('./js/modules/i18n');
const {
getEnvironment,
setEnvironment,
parseEnvironment,
} = require('./ts/environment');
const config = url.parse(window.location.toString(), true).query;
const { locale } = config;
const localeMessages = ipcRenderer.sendSync('locale-data');
setEnvironment(parseEnvironment(config.environment));
window.getEnvironment = () => config.environment;
window.getEnvironment = getEnvironment;
window.getVersion = () => config.version;
window.getAppInstance = () => config.appInstance;

View File

@ -3,21 +3,25 @@
const path = require('path');
const { app } = require('electron');
let environment;
const {
Environment,
getEnvironment,
setEnvironment,
parseEnvironment,
} = require('../ts/environment');
// In production mode, NODE_ENV cannot be customized by the user
if (!app.isPackaged) {
environment = process.env.NODE_ENV || 'development';
if (app.isPackaged) {
setEnvironment(Environment.Production);
} else {
environment = 'production';
setEnvironment(parseEnvironment(process.env.NODE_ENV || 'development'));
}
// Set environment vars to configure node-config before requiring it
process.env.NODE_ENV = environment;
process.env.NODE_ENV = getEnvironment();
process.env.NODE_CONFIG_DIR = path.join(__dirname, '..', 'config');
if (environment === 'production') {
if (getEnvironment() === Environment.Production) {
// harden production config against the local env
process.env.NODE_CONFIG = '';
process.env.NODE_CONFIG_STRICT_MODE = true;
@ -30,9 +34,10 @@ if (environment === 'production') {
}
// We load config after we've made our modifications to NODE_ENV
// eslint-disable-next-line import/order
const config = require('config');
config.environment = environment;
config.environment = getEnvironment();
config.enableHttp = process.env.SIGNAL_ENABLE_HTTP;
// Log resulting env vars in use by config

View File

@ -7,10 +7,16 @@ const { ipcRenderer } = require('electron');
const url = require('url');
const copyText = require('copy-text-to-clipboard');
const i18n = require('./js/modules/i18n');
const {
getEnvironment,
setEnvironment,
parseEnvironment,
} = require('./ts/environment');
const config = url.parse(window.location.toString(), true).query;
const { locale } = config;
const localeMessages = ipcRenderer.sendSync('locale-data');
setEnvironment(parseEnvironment(config.environment));
window.getVersion = () => config.version;
window.theme = config.theme;
@ -21,7 +27,7 @@ window.copyText = copyText;
window.nodeSetImmediate = setImmediate;
window.getNodeVersion = () => config.node_version;
window.getEnvironment = () => config.environment;
window.getEnvironment = getEnvironment;
require('./ts/logging/set_up_renderer_logging');

View File

@ -23,6 +23,7 @@ const rimraf = require('rimraf');
const electronRemote = require('electron').remote;
const crypto = require('../../ts/Crypto');
const { getEnvironment } = require('../../ts/environment');
const { dialog, BrowserWindow } = electronRemote;
@ -1198,7 +1199,7 @@ function deleteAll(pattern) {
const ARCHIVE_NAME = 'messages.tar.gz';
async function exportToDirectory(directory, options) {
const env = window.getEnvironment();
const env = getEnvironment();
if (env !== 'test') {
throw new Error('export is only supported in test mode');
}
@ -1266,7 +1267,7 @@ async function importFromDirectory(directory, options) {
const archivePath = path.join(directory, ARCHIVE_NAME);
if (fs.existsSync(archivePath)) {
const env = window.getEnvironment();
const env = getEnvironment();
if (env !== 'test') {
throw new Error('import is only supported in test mode');
}

View File

@ -29,7 +29,7 @@
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
"test": "yarn test-node && yarn test-electron",
"test-electron": "yarn grunt test",
"test-node": "electron-mocha --require test/setup-test-node.js --recursive test/app test/modules ts/test-node ts/test-both",
"test-node": "electron-mocha --file test/setup-test-node.js --recursive test/app test/modules ts/test-node ts/test-both",
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/app test/modules ts/test-node ts/test-both",
"eslint": "eslint .",
"lint": "yarn format --list-different && yarn eslint",

View File

@ -11,14 +11,20 @@ const url = require('url');
const i18n = require('./js/modules/i18n');
const { ConfirmationModal } = require('./ts/components/ConfirmationModal');
const { makeGetter, makeSetter } = require('./preload_utils');
const {
getEnvironment,
setEnvironment,
parseEnvironment,
} = require('./ts/environment');
const { nativeTheme } = remote.require('electron');
const config = url.parse(window.location.toString(), true).query;
const { locale } = config;
const localeMessages = ipcRenderer.sendSync('locale-data');
setEnvironment(parseEnvironment(config.environment));
window.getEnvironment = () => config.environment;
window.getEnvironment = getEnvironment;
window.getVersion = () => config.version;
window.theme = config.theme;
window.i18n = i18n.setup(locale, localeMessages);

View File

@ -11,6 +11,12 @@ try {
const client = require('libsignal-client');
const _ = require('lodash');
const { installGetter, installSetter } = require('./preload_utils');
const {
getEnvironment,
setEnvironment,
parseEnvironment,
Environment,
} = require('./ts/environment');
const { remote } = electron;
const { app } = remote;
@ -19,9 +25,11 @@ try {
window.PROTO_ROOT = 'protos';
const config = require('url').parse(window.location.toString(), true).query;
setEnvironment(parseEnvironment(config.environment));
let title = config.name;
if (config.environment !== 'production') {
title += ` - ${config.environment}`;
if (getEnvironment() !== Environment.Production) {
title += ` - ${getEnvironment()}`;
}
if (config.appInstance) {
title += ` - ${config.appInstance}`;
@ -37,7 +45,7 @@ try {
window.platform = process.platform;
window.getTitle = () => title;
window.getEnvironment = () => config.environment;
window.getEnvironment = getEnvironment;
window.getAppInstance = () => config.appInstance;
window.getVersion = () => config.version;
window.getExpiration = () => {

View File

@ -7,10 +7,16 @@ const { ipcRenderer, remote } = require('electron');
const url = require('url');
const i18n = require('./js/modules/i18n');
const {
getEnvironment,
setEnvironment,
parseEnvironment,
} = require('./ts/environment');
const config = url.parse(window.location.toString(), true).query;
const { locale } = config;
const localeMessages = ipcRenderer.sendSync('locale-data');
setEnvironment(parseEnvironment(config.environment));
const { nativeTheme } = remote.require('electron');
@ -33,7 +39,7 @@ window.subscribeToSystemThemeChange = fn => {
});
};
window.getEnvironment = () => config.environment;
window.getEnvironment = getEnvironment;
window.getVersion = () => config.version;
window.getAppInstance = () => config.appInstance;

View File

@ -10,6 +10,11 @@ const config = require('url').parse(window.location.toString(), true).query;
const { noop, uniqBy } = require('lodash');
const pMap = require('p-map');
const { deriveStickerPackKey } = require('../ts/Crypto');
const {
getEnvironment,
setEnvironment,
parseEnvironment,
} = require('../ts/environment');
const { makeGetter } = require('../preload_utils');
const { dialog } = remote;
@ -21,9 +26,11 @@ const MAX_STICKER_DIMENSION = STICKER_SIZE;
const MAX_WEBP_STICKER_BYTE_LENGTH = 100 * 1024;
const MAX_ANIMATED_STICKER_BYTE_LENGTH = 300 * 1024;
setEnvironment(parseEnvironment(config.environment));
window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/';
window.PROTO_ROOT = '../../protos';
window.getEnvironment = () => config.environment;
window.getEnvironment = getEnvironment;
window.getVersion = () => config.version;
window.getGuid = require('uuid/v4');
window.PQueue = require('p-queue').default;

View File

@ -3,6 +3,12 @@
/* eslint-disable no-console */
const { setEnvironment, Environment } = require('../ts/environment');
before(() => {
setEnvironment(Environment.Test);
});
// To replicate logic we have on the client side
global.window = {
log: {

49
ts/environment.ts Normal file
View File

@ -0,0 +1,49 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Many places rely on this enum being a string.
export enum Environment {
Development = 'development',
Production = 'production',
Staging = 'staging',
Test = 'test',
TestLib = 'test-lib',
}
let environment: undefined | Environment;
export function getEnvironment(): Environment {
if (environment === undefined) {
// This should never happen—we should always have initialized the environment by this
// point. It'd be nice to log here but the logger depends on the environment and we
// can't have circular dependencies.
return Environment.Production;
}
return environment;
}
/**
* Sets the current environment. Should be called early in a process's life, and can only
* be called once.
*/
export function setEnvironment(env: Environment): void {
if (environment !== undefined) {
throw new Error('Environment has already been set');
}
environment = env;
}
const ENVIRONMENTS_BY_STRING = new Map<string, Environment>([
['development', Environment.Development],
['production', Environment.Production],
['staging', Environment.Staging],
['test', Environment.Test],
['test-lib', Environment.TestLib],
]);
export function parseEnvironment(value: unknown): Environment {
if (typeof value !== 'string') {
return Environment.Production;
}
const result = ENVIRONMENTS_BY_STRING.get(value);
return result || Environment.Production;
}

33
ts/logging/log.ts Normal file
View File

@ -0,0 +1,33 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { noop } from 'lodash';
import { LogLevel } from './shared';
type LogAtLevelFnType = (
level: LogLevel,
...args: ReadonlyArray<unknown>
) => void;
let logAtLevel: LogAtLevelFnType = noop;
let hasInitialized = false;
type LogFn = (...args: ReadonlyArray<unknown>) => void;
export const fatal: LogFn = (...args) => logAtLevel(LogLevel.Fatal, ...args);
export const error: LogFn = (...args) => logAtLevel(LogLevel.Error, ...args);
export const warn: LogFn = (...args) => logAtLevel(LogLevel.Warn, ...args);
export const info: LogFn = (...args) => logAtLevel(LogLevel.Info, ...args);
export const debug: LogFn = (...args) => logAtLevel(LogLevel.Debug, ...args);
export const trace: LogFn = (...args) => logAtLevel(LogLevel.Trace, ...args);
/**
* Sets the low-level logging interface. Should be called early in a process's life, and
* can only be called once.
*/
export function setLogAtLevel(log: LogAtLevelFnType): void {
if (hasInitialized) {
throw new Error('Logger has already been initialized');
}
logAtLevel = log;
hasInitialized = true;
}

View File

@ -19,6 +19,7 @@ import {
getLogLevelString,
isLogEntry,
} from './shared';
import * as log from './log';
import { reallyJsonStringify } from '../util/reallyJsonStringify';
// To make it easier to visually scan logs, we make all levels the same length
@ -33,13 +34,13 @@ function now() {
return date.toJSON();
}
function log(...args: ReadonlyArray<unknown>) {
function consoleLog(...args: ReadonlyArray<unknown>) {
logAtLevel(LogLevel.Info, ...args);
}
if (window.console) {
console._log = console.log;
console.log = log;
console.log = consoleLog;
}
// The mechanics of preparing a log for publish
@ -126,13 +127,15 @@ function logAtLevel(level: LogLevel, ...args: ReadonlyArray<unknown>): void {
});
}
log.setLogAtLevel(logAtLevel);
window.log = {
fatal: _.partial(logAtLevel, LogLevel.Fatal),
error: _.partial(logAtLevel, LogLevel.Error),
warn: _.partial(logAtLevel, LogLevel.Warn),
info: _.partial(logAtLevel, LogLevel.Info),
debug: _.partial(logAtLevel, LogLevel.Debug),
trace: _.partial(logAtLevel, LogLevel.Trace),
fatal: log.fatal,
error: log.error,
warn: log.warn,
info: log.info,
debug: log.debug,
trace: log.trace,
fetch,
publish,
};

View File

@ -16,15 +16,17 @@ import {
get,
groupBy,
isFunction,
isObject,
last,
map,
omit,
set,
} from 'lodash';
import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto';
import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message';
import { createBatcher } from '../util/batcher';
import { assert } from '../util/assert';
import { cleanDataForIpc } from './cleanDataForIpc';
import {
ConversationModelCollectionType,
@ -231,7 +233,6 @@ const dataInterface: ClientInterface = {
// Client-side only, and test-only
_removeConversations,
_cleanData,
_jobs,
};
@ -251,55 +252,22 @@ const channelsAsUnknown = fromPairs(
const channels: ServerInterface = channelsAsUnknown;
// When IPC arguments are prepared for the cross-process send, they are serialized with
// the [structured clone algorithm][0]. We can't send some values, like BigNumbers and
// functions (both of which come from protobufjs), so we clean them up.
// [0]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
function _cleanData(data: any, path = 'root') {
if (data === null || data === undefined) {
window.log.warn(`_cleanData: null or undefined value at path ${path}`);
function _cleanData(
data: unknown
): ReturnType<typeof cleanDataForIpc>['cleaned'] {
const { cleaned, pathsChanged } = cleanDataForIpc(data);
return data;
if (pathsChanged.length) {
window.log.info(
`_cleanData cleaned the following paths: ${pathsChanged.join(', ')}`
);
}
if (
typeof data === 'string' ||
typeof data === 'number' ||
typeof data === 'boolean'
) {
return data;
}
return cleaned;
}
const keys = Object.keys(data);
const max = keys.length;
for (let index = 0; index < max; index += 1) {
const key = keys[index];
const value = data[key];
if (value === null || value === undefined) {
continue;
}
if (isFunction(value)) {
delete data[key];
} else if (isFunction(value.toNumber)) {
data[key] = value.toNumber();
} else if (Array.isArray(value)) {
data[key] = value.map((item, mapIndex) =>
_cleanData(item, `${path}.${key}.${mapIndex}`)
);
} else if (isObject(value)) {
data[key] = _cleanData(value, `${path}.${key}`);
} else if (
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean'
) {
window.log.info(`_cleanData: key ${key} had type ${typeof value}`);
}
}
return data;
function _cleanMessageData(data: MessageType): MessageType {
return _cleanData(omit(data, ['dataMessage']));
}
async function _shutdown() {
@ -764,7 +732,12 @@ function updateConversation(data: ConversationType) {
}
async function updateConversations(array: Array<ConversationType>) {
await channels.updateConversations(array);
const { cleaned, pathsChanged } = cleanDataForIpc(array);
assert(
!pathsChanged.length,
`Paths were cleaned: ${JSON.stringify(pathsChanged)}`
);
await channels.updateConversations(cleaned);
}
async function removeConversation(
@ -884,7 +857,9 @@ async function saveMessage(
data: MessageType,
{ forceSave, Message }: { forceSave?: boolean; Message: typeof MessageModel }
) {
const id = await channels.saveMessage(_cleanData(data), { forceSave });
const id = await channels.saveMessage(_cleanMessageData(data), {
forceSave,
});
Message.updateTimers();
return id;
@ -894,7 +869,10 @@ async function saveMessages(
arrayOfMessages: Array<MessageType>,
{ forceSave }: { forceSave?: boolean } = {}
) {
await channels.saveMessages(_cleanData(arrayOfMessages), { forceSave });
await channels.saveMessages(
arrayOfMessages.map(message => _cleanMessageData(message)),
{ forceSave }
);
}
async function removeMessage(

View File

@ -393,7 +393,6 @@ export type ClientInterface = DataInterface & {
// Client-side only, and test-only
_removeConversations: (ids: Array<string>) => Promise<void>;
_cleanData: (data: any, path?: string) => any;
_jobs: { [id: string]: ClientJobType };
};

163
ts/sql/cleanDataForIpc.ts Normal file
View File

@ -0,0 +1,163 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isPlainObject } from 'lodash';
import { isIterable } from '../util/isIterable';
/**
* IPC arguments are serialized with the [structured clone algorithm][0], but we can only
* save some data types to disk.
*
* This cleans the data so it's roughly JSON-serializable, though it does not handle
* every case. You can see the expected behavior in the tests. Notably, we try to convert
* protobufjs numbers to JavaScript numbers, and we don't touch ArrayBuffers.
*
* [0]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
*/
export function cleanDataForIpc(
data: unknown
): {
// `any`s are dangerous but it's difficult (impossible?) to type this with generics.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cleaned: any;
pathsChanged: Array<string>;
} {
const pathsChanged: Array<string> = [];
const cleaned = cleanDataInner(data, 'root', pathsChanged);
return { cleaned, pathsChanged };
}
// These type definitions are lifted from [this GitHub comment][1].
//
// [1]: https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540
type CleanedDataValue =
| string
| number
| boolean
| null
| undefined
| CleanedObject
| CleanedArray;
/* eslint-disable no-restricted-syntax */
interface CleanedObject {
[x: string]: CleanedDataValue;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CleanedArray extends Array<CleanedDataValue> {}
/* eslint-enable no-restricted-syntax */
function cleanDataInner(
data: unknown,
path: string,
pathsChanged: Array<string>
): CleanedDataValue {
switch (typeof data) {
case 'undefined':
case 'boolean':
case 'number':
case 'string':
return data;
case 'bigint':
pathsChanged.push(path);
return data.toString();
case 'function':
// For backwards compatibility with previous versions of this function, we clean
// functions but don't mark them as cleaned.
return undefined;
case 'object': {
if (data === null) {
return null;
}
if (Array.isArray(data)) {
const result: CleanedArray = [];
data.forEach((item, index) => {
const indexPath = `${path}.${index}`;
if (item === undefined || item === null) {
pathsChanged.push(indexPath);
} else {
result.push(cleanDataInner(item, indexPath, pathsChanged));
}
});
return result;
}
if (data instanceof Map) {
const result: CleanedObject = {};
pathsChanged.push(path);
data.forEach((value, key) => {
if (typeof key === 'string') {
result[key] = cleanDataInner(
value,
`${path}.<map value at ${key}>`,
pathsChanged
);
} else {
pathsChanged.push(`${path}.<map key ${String(key)}>`);
}
});
return result;
}
if (data instanceof Date) {
pathsChanged.push(path);
return Number.isNaN(data.valueOf()) ? undefined : data.toISOString();
}
if (data instanceof ArrayBuffer) {
pathsChanged.push(path);
return undefined;
}
const dataAsRecord = data as Record<string, unknown>;
if (
'toNumber' in dataAsRecord &&
typeof dataAsRecord.toNumber === 'function'
) {
// We clean this just in case `toNumber` returns something bogus.
return cleanDataInner(dataAsRecord.toNumber(), path, pathsChanged);
}
if (isIterable(dataAsRecord)) {
const result: CleanedArray = [];
let index = 0;
pathsChanged.push(path);
// `for ... of` is the cleanest way to go through "generic" iterables without
// a helper library.
// eslint-disable-next-line no-restricted-syntax
for (const value of dataAsRecord) {
result.push(
cleanDataInner(
value,
`${path}.<iterator index ${index}>`,
pathsChanged
)
);
index += 1;
}
return result;
}
// We'll still try to clean non-plain objects, but we want to mark that they've
// changed.
if (!isPlainObject(data)) {
pathsChanged.push(path);
}
const result: CleanedObject = {};
// Conveniently, `Object.entries` removes symbol keys.
Object.entries(dataAsRecord).forEach(([key, value]) => {
result[key] = cleanDataInner(value, `${path}.${key}`, pathsChanged);
});
return result;
}
default: {
pathsChanged.push(path);
return undefined;
}
}
}

View File

@ -0,0 +1,41 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { parseEnvironment, Environment } from '../environment';
describe('environment utilities', () => {
describe('parseEnvironment', () => {
it('returns Environment.Production for non-strings', () => {
assert.equal(parseEnvironment(undefined), Environment.Production);
assert.equal(parseEnvironment(0), Environment.Production);
});
it('returns Environment.Production for invalid strings', () => {
assert.equal(parseEnvironment(''), Environment.Production);
assert.equal(parseEnvironment(' development '), Environment.Production);
assert.equal(parseEnvironment('PRODUCTION'), Environment.Production);
});
it('parses "development" as Environment.Development', () => {
assert.equal(parseEnvironment('development'), Environment.Development);
});
it('parses "production" as Environment.Production', () => {
assert.equal(parseEnvironment('production'), Environment.Production);
});
it('parses "staging" as Environment.Staging', () => {
assert.equal(parseEnvironment('staging'), Environment.Staging);
});
it('parses "test" as Environment.Test', () => {
assert.equal(parseEnvironment('test'), Environment.Test);
});
it('parses "test-lib" as Environment.TestLib', () => {
assert.equal(parseEnvironment('test-lib'), Environment.TestLib);
});
});
});

View File

@ -0,0 +1,254 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { noop } from 'lodash';
import { cleanDataForIpc } from '../../sql/cleanDataForIpc';
describe('cleanDataForIpc', () => {
it('does nothing to JSON primitives', () => {
['', 'foo bar', 0, 123, true, false, null].forEach(value => {
assert.deepEqual(cleanDataForIpc(value), {
cleaned: value,
pathsChanged: [],
});
});
});
it('does nothing to undefined', () => {
// Though `undefined` is not technically JSON-serializable, we don't clean it because
// its key is dropped.
assert.deepEqual(cleanDataForIpc(undefined), {
cleaned: undefined,
pathsChanged: [],
});
});
it('converts BigInts to strings', () => {
assert.deepEqual(cleanDataForIpc(BigInt(0)), {
cleaned: '0',
pathsChanged: ['root'],
});
assert.deepEqual(cleanDataForIpc(BigInt(123)), {
cleaned: '123',
pathsChanged: ['root'],
});
assert.deepEqual(cleanDataForIpc(BigInt(-123)), {
cleaned: '-123',
pathsChanged: ['root'],
});
});
it('converts functions to `undefined` but does not mark them as cleaned, for backwards compatibility', () => {
assert.deepEqual(cleanDataForIpc(noop), {
cleaned: undefined,
pathsChanged: [],
});
});
it('converts symbols to `undefined`', () => {
assert.deepEqual(cleanDataForIpc(Symbol('test')), {
cleaned: undefined,
pathsChanged: ['root'],
});
});
it('converts ArrayBuffers to `undefined`', () => {
assert.deepEqual(cleanDataForIpc(new ArrayBuffer(2)), {
cleaned: undefined,
pathsChanged: ['root'],
});
});
it('converts valid dates to ISO strings', () => {
assert.deepEqual(cleanDataForIpc(new Date(924588548000)), {
cleaned: '1999-04-20T06:09:08.000Z',
pathsChanged: ['root'],
});
});
it('converts invalid dates to `undefined`', () => {
assert.deepEqual(cleanDataForIpc(new Date(NaN)), {
cleaned: undefined,
pathsChanged: ['root'],
});
});
it('converts other iterables to arrays', () => {
assert.deepEqual(cleanDataForIpc(new Uint8Array([1, 2, 3])), {
cleaned: [1, 2, 3],
pathsChanged: ['root'],
});
assert.deepEqual(cleanDataForIpc(new Float32Array([1, 2, 3])), {
cleaned: [1, 2, 3],
pathsChanged: ['root'],
});
function* generator() {
yield 1;
yield 2;
}
assert.deepEqual(cleanDataForIpc(generator()), {
cleaned: [1, 2],
pathsChanged: ['root'],
});
});
it('deeply cleans arrays, removing `undefined` and `null`s', () => {
const result = cleanDataForIpc([
12,
Symbol('top level symbol'),
{ foo: 3, symb: Symbol('nested symbol 1') },
[45, Symbol('nested symbol 2')],
undefined,
null,
]);
assert.deepEqual(result.cleaned, [
12,
undefined,
{
foo: 3,
symb: undefined,
},
[45, undefined],
]);
assert.sameMembers(result.pathsChanged, [
'root.1',
'root.2.symb',
'root.3.1',
'root.4',
'root.5',
]);
});
it('deeply cleans sets and converts them to arrays', () => {
const result = cleanDataForIpc(
new Set([
12,
Symbol('top level symbol'),
{ foo: 3, symb: Symbol('nested symbol 1') },
[45, Symbol('nested symbol 2')],
])
);
assert.isArray(result.cleaned);
assert.sameDeepMembers(result.cleaned, [
12,
undefined,
{
foo: 3,
symb: undefined,
},
[45, undefined],
]);
assert.sameMembers(result.pathsChanged, [
'root',
'root.<iterator index 1>',
'root.<iterator index 2>.symb',
'root.<iterator index 3>.1',
]);
});
it('deeply cleans maps and converts them to objects', () => {
const result = cleanDataForIpc(
new Map<unknown, unknown>([
['key 1', 'value'],
[Symbol('symbol key'), 'dropped'],
['key 2', ['foo', Symbol('nested symbol')]],
[3, 'dropped'],
[BigInt(4), 'dropped'],
])
);
assert.deepEqual(result.cleaned, {
'key 1': 'value',
'key 2': ['foo', undefined],
});
assert.sameMembers(result.pathsChanged, [
'root',
'root.<map key Symbol(symbol key)>',
'root.<map value at key 2>.1',
'root.<map key 3>',
'root.<map key 4>',
]);
});
it('calls `toNumber` when available', () => {
assert.deepEqual(
cleanDataForIpc([
{
toNumber() {
return 5;
},
},
{
toNumber() {
return Symbol('bogus');
},
},
]),
{
cleaned: [5, undefined],
pathsChanged: ['root.1'],
}
);
});
it('deeply cleans objects with a `null` prototype', () => {
const value = Object.assign(Object.create(null), {
'key 1': 'value',
[Symbol('symbol key')]: 'dropped',
'key 2': ['foo', Symbol('nested symbol')],
});
const result = cleanDataForIpc(value);
assert.deepEqual(result.cleaned, {
'key 1': 'value',
'key 2': ['foo', undefined],
});
assert.sameMembers(result.pathsChanged, ['root.key 2.1']);
});
it('deeply cleans objects with a prototype of `Object.prototype`', () => {
const value = {
'key 1': 'value',
[Symbol('symbol key')]: 'dropped',
'key 2': ['foo', Symbol('nested symbol')],
};
const result = cleanDataForIpc(value);
assert.deepEqual(result.cleaned, {
'key 1': 'value',
'key 2': ['foo', undefined],
});
assert.sameMembers(result.pathsChanged, ['root.key 2.1']);
});
it('deeply cleans class instances', () => {
class Person {
public toBeDiscarded = Symbol('to be discarded');
constructor(public firstName: string, public lastName: string) {}
get name() {
return this.getName();
}
getName() {
return `${this.firstName} ${this.lastName}`;
}
}
const person = new Person('Selena', 'Gomez');
const result = cleanDataForIpc(person);
assert.deepEqual(result.cleaned, {
firstName: 'Selena',
lastName: 'Gomez',
toBeDiscarded: undefined,
});
assert.sameMembers(result.pathsChanged, ['root', 'root.toBeDiscarded']);
});
});

View File

@ -0,0 +1,18 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as chai from 'chai';
import { assert } from '../../util/assert';
describe('assert', () => {
it('does nothing if the assertion passes', () => {
assert(true, 'foo bar');
});
it("throws because we're in a test environment", () => {
chai.assert.throws(() => {
assert(false, 'foo bar');
}, 'foo bar');
});
});

View File

@ -0,0 +1,50 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { isIterable } from '../../util/isIterable';
describe('isIterable', () => {
it('returns false for non-iterables', () => {
assert.isFalse(isIterable(undefined));
assert.isFalse(isIterable(null));
assert.isFalse(isIterable(123));
assert.isFalse(isIterable({ foo: 'bar' }));
assert.isFalse(
isIterable({
length: 2,
'0': 'fake',
'1': 'array',
})
);
});
it('returns true for iterables', () => {
assert.isTrue(isIterable('strings are iterable'));
assert.isTrue(isIterable(['arrays too']));
assert.isTrue(isIterable(new Set('and sets')));
assert.isTrue(isIterable(new Map([['and', 'maps']])));
assert.isTrue(
isIterable({
[Symbol.iterator]() {
return {
next() {
return {
value: 'endless iterable',
done: false,
};
},
};
},
})
);
assert.isTrue(
isIterable(
(function* generators() {
yield 123;
})()
)
);
});
});

21
ts/util/assert.ts Normal file
View File

@ -0,0 +1,21 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { getEnvironment, Environment } from '../environment';
import * as log from '../logging/log';
/**
* In production, logs an error and continues. In all other environments, throws an error.
*/
export function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
const err = new Error(message);
if (getEnvironment() !== Environment.Production) {
if (getEnvironment() === Environment.Development) {
debugger; // eslint-disable-line no-debugger
}
throw err;
}
log.error(err);
}
}

9
ts/util/isIterable.ts Normal file
View File

@ -0,0 +1,9 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function isIterable(value: unknown): value is Iterable<unknown> {
return (
(typeof value === 'object' && value !== null && Symbol.iterator in value) ||
typeof value === 'string'
);
}

3
ts/window.d.ts vendored
View File

@ -30,6 +30,7 @@ import * as Groups from './groups';
import * as Crypto from './Crypto';
import * as RemoteConfig from './RemoteConfig';
import * as OS from './OS';
import { getEnvironment } from './environment';
import * as zkgroup from './util/zkgroup';
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
import * as Attachment from './types/Attachment';
@ -141,7 +142,7 @@ declare global {
getCallSystemNotification: () => Promise<boolean>;
getConversations: () => ConversationModelCollectionType;
getCountMutedConversations: () => Promise<boolean>;
getEnvironment: () => string;
getEnvironment: typeof getEnvironment;
getExpiration: () => string;
getGuid: () => string;
getInboxCollection: () => ConversationModelCollectionType;