diff --git a/about_preload.js b/about_preload.js index cc5fdc9d155..1d7d969b3a9 100644 --- a/about_preload.js +++ b/about_preload.js @@ -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; diff --git a/app/config.js b/app/config.js index 233318fecbf..971a65ef99b 100644 --- a/app/config.js +++ b/app/config.js @@ -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 diff --git a/debug_log_preload.js b/debug_log_preload.js index 9061523bff0..1e97475bca7 100644 --- a/debug_log_preload.js +++ b/debug_log_preload.js @@ -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'); diff --git a/js/modules/backup.js b/js/modules/backup.js index d0d27a18cd0..80d10362980 100644 --- a/js/modules/backup.js +++ b/js/modules/backup.js @@ -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'); } diff --git a/package.json b/package.json index 0f45affbf72..bb7a4a01e6b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/permissions_popup_preload.js b/permissions_popup_preload.js index a15e9b5d15d..63730f95db9 100644 --- a/permissions_popup_preload.js +++ b/permissions_popup_preload.js @@ -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); diff --git a/preload.js b/preload.js index e659ba6c52d..ff45de54ed7 100644 --- a/preload.js +++ b/preload.js @@ -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 = () => { diff --git a/settings_preload.js b/settings_preload.js index 19a10438d8b..ca280c26c13 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -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; diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index f60a1243f2f..0ee8159f0bd 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -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; diff --git a/test/setup-test-node.js b/test/setup-test-node.js index e0b8d2674a3..43b0d1b86cd 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -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: { diff --git a/ts/environment.ts b/ts/environment.ts new file mode 100644 index 00000000000..9aab86c7052 --- /dev/null +++ b/ts/environment.ts @@ -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([ + ['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; +} diff --git a/ts/logging/log.ts b/ts/logging/log.ts new file mode 100644 index 00000000000..6e5e7f0bcdd --- /dev/null +++ b/ts/logging/log.ts @@ -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 +) => void; + +let logAtLevel: LogAtLevelFnType = noop; +let hasInitialized = false; + +type LogFn = (...args: ReadonlyArray) => 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; +} diff --git a/ts/logging/set_up_renderer_logging.ts b/ts/logging/set_up_renderer_logging.ts index 95cbee4f1e3..d4c6d095147 100644 --- a/ts/logging/set_up_renderer_logging.ts +++ b/ts/logging/set_up_renderer_logging.ts @@ -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) { +function consoleLog(...args: ReadonlyArray) { 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): 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, }; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 84563ce9b1f..14cdc41b908 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -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['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) { - 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, { forceSave }: { forceSave?: boolean } = {} ) { - await channels.saveMessages(_cleanData(arrayOfMessages), { forceSave }); + await channels.saveMessages( + arrayOfMessages.map(message => _cleanMessageData(message)), + { forceSave } + ); } async function removeMessage( diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 09680193904..46932556311 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -393,7 +393,6 @@ export type ClientInterface = DataInterface & { // Client-side only, and test-only _removeConversations: (ids: Array) => Promise; - _cleanData: (data: any, path?: string) => any; _jobs: { [id: string]: ClientJobType }; }; diff --git a/ts/sql/cleanDataForIpc.ts b/ts/sql/cleanDataForIpc.ts new file mode 100644 index 00000000000..40c896d8522 --- /dev/null +++ b/ts/sql/cleanDataForIpc.ts @@ -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; +} { + const pathsChanged: Array = []; + 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 {} +/* eslint-enable no-restricted-syntax */ + +function cleanDataInner( + data: unknown, + path: string, + pathsChanged: Array +): 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}.`, + pathsChanged + ); + } else { + pathsChanged.push(`${path}.`); + } + }); + 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; + + 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}.`, + 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; + } + } +} diff --git a/ts/test-both/environment.ts b/ts/test-both/environment.ts new file mode 100644 index 00000000000..604c47cda03 --- /dev/null +++ b/ts/test-both/environment.ts @@ -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); + }); + }); +}); diff --git a/ts/test-both/sql/cleanDataForIpc_test.ts b/ts/test-both/sql/cleanDataForIpc_test.ts new file mode 100644 index 00000000000..8f8c2a2585c --- /dev/null +++ b/ts/test-both/sql/cleanDataForIpc_test.ts @@ -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.', + 'root..symb', + 'root..1', + ]); + }); + + it('deeply cleans maps and converts them to objects', () => { + const result = cleanDataForIpc( + new Map([ + ['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.', + 'root..1', + 'root.', + 'root.', + ]); + }); + + 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']); + }); +}); diff --git a/ts/test-both/util/assert_test.ts b/ts/test-both/util/assert_test.ts new file mode 100644 index 00000000000..d69d0530a59 --- /dev/null +++ b/ts/test-both/util/assert_test.ts @@ -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'); + }); +}); diff --git a/ts/test-both/util/isIterable_test.ts b/ts/test-both/util/isIterable_test.ts new file mode 100644 index 00000000000..932d2f3af31 --- /dev/null +++ b/ts/test-both/util/isIterable_test.ts @@ -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; + })() + ) + ); + }); +}); diff --git a/ts/util/assert.ts b/ts/util/assert.ts new file mode 100644 index 00000000000..1c28e1b3096 --- /dev/null +++ b/ts/util/assert.ts @@ -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); + } +} diff --git a/ts/util/isIterable.ts b/ts/util/isIterable.ts new file mode 100644 index 00000000000..04ff4dd7cd0 --- /dev/null +++ b/ts/util/isIterable.ts @@ -0,0 +1,9 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function isIterable(value: unknown): value is Iterable { + return ( + (typeof value === 'object' && value !== null && Symbol.iterator in value) || + typeof value === 'string' + ); +} diff --git a/ts/window.d.ts b/ts/window.d.ts index 2490fb820ba..35fa3590b27 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -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; getConversations: () => ConversationModelCollectionType; getCountMutedConversations: () => Promise; - getEnvironment: () => string; + getEnvironment: typeof getEnvironment; getExpiration: () => string; getGuid: () => string; getInboxCollection: () => ConversationModelCollectionType;