Add backup comparator test harness
This commit is contained in:
parent
98eb6dec68
commit
84f1d98020
17 changed files with 339 additions and 124 deletions
|
@ -2721,6 +2721,7 @@ ipc.on('get-config', async event => {
|
||||||
dnsFallback: await getDNSFallback(),
|
dnsFallback: await getDNSFallback(),
|
||||||
disableIPv6: DISABLE_IPV6,
|
disableIPv6: DISABLE_IPV6,
|
||||||
ciBackupPath: config.get<string | null>('ciBackupPath') || undefined,
|
ciBackupPath: config.get<string | null>('ciBackupPath') || undefined,
|
||||||
|
ciIsPlaintextBackup: config.get<boolean>('ciIsPlaintextBackup'),
|
||||||
nodeVersion: process.versions.node,
|
nodeVersion: process.versions.node,
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
osRelease: os.release(),
|
osRelease: os.release(),
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
"updatesEnabled": false,
|
"updatesEnabled": false,
|
||||||
"ciMode": false,
|
"ciMode": false,
|
||||||
"ciBackupPath": null,
|
"ciBackupPath": null,
|
||||||
|
"ciIsPlaintextBackup": false,
|
||||||
"forcePreloadBundle": false,
|
"forcePreloadBundle": false,
|
||||||
"openDevTools": false,
|
"openDevTools": false,
|
||||||
"buildCreation": 0,
|
"buildCreation": 0,
|
||||||
|
|
21
ts/CI.ts
21
ts/CI.ts
|
@ -8,7 +8,7 @@ import type { MessageAttributesType } from './model-types.d';
|
||||||
import * as log from './logging/log';
|
import * as log from './logging/log';
|
||||||
import { explodePromise } from './util/explodePromise';
|
import { explodePromise } from './util/explodePromise';
|
||||||
import { AccessType, ipcInvoke } from './sql/channels';
|
import { AccessType, ipcInvoke } from './sql/channels';
|
||||||
import { backupsService } from './services/backups';
|
import { backupsService, BackupType } from './services/backups';
|
||||||
import { SECOND } from './util/durations';
|
import { SECOND } from './util/durations';
|
||||||
import { isSignalRoute } from './util/signalRoutes';
|
import { isSignalRoute } from './util/signalRoutes';
|
||||||
import { strictAssert } from './util/assert';
|
import { strictAssert } from './util/assert';
|
||||||
|
@ -18,6 +18,7 @@ type ResolveType = (data: unknown) => void;
|
||||||
export type CIType = {
|
export type CIType = {
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
backupData?: Uint8Array;
|
backupData?: Uint8Array;
|
||||||
|
isPlaintextBackup?: boolean;
|
||||||
getConversationId: (address: string | null) => string | null;
|
getConversationId: (address: string | null) => string | null;
|
||||||
getMessagesBySentAt(
|
getMessagesBySentAt(
|
||||||
sentAt: number
|
sentAt: number
|
||||||
|
@ -34,15 +35,21 @@ export type CIType = {
|
||||||
) => unknown;
|
) => unknown;
|
||||||
openSignalRoute(url: string): Promise<void>;
|
openSignalRoute(url: string): Promise<void>;
|
||||||
exportBackupToDisk(path: string): Promise<void>;
|
exportBackupToDisk(path: string): Promise<void>;
|
||||||
|
exportPlaintextBackupToDisk(path: string): Promise<void>;
|
||||||
unlink: () => void;
|
unlink: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCIOptionsType = Readonly<{
|
export type GetCIOptionsType = Readonly<{
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
backupData?: Uint8Array;
|
backupData?: Uint8Array;
|
||||||
|
isPlaintextBackup?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function getCI({ deviceName, backupData }: GetCIOptionsType): CIType {
|
export function getCI({
|
||||||
|
deviceName,
|
||||||
|
backupData,
|
||||||
|
isPlaintextBackup,
|
||||||
|
}: GetCIOptionsType): CIType {
|
||||||
const eventListeners = new Map<string, Array<ResolveType>>();
|
const eventListeners = new Map<string, Array<ResolveType>>();
|
||||||
const completedEvents = new Map<string, Array<unknown>>();
|
const completedEvents = new Map<string, Array<unknown>>();
|
||||||
|
|
||||||
|
@ -164,6 +171,14 @@ export function getCI({ deviceName, backupData }: GetCIOptionsType): CIType {
|
||||||
await backupsService.exportToDisk(path);
|
await backupsService.exportToDisk(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportPlaintextBackupToDisk(path: string) {
|
||||||
|
await backupsService.exportToDisk(
|
||||||
|
path,
|
||||||
|
undefined,
|
||||||
|
BackupType.TestOnlyPlaintext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function unlink() {
|
function unlink() {
|
||||||
window.Whisper.events.trigger('unlinkAndDisconnect');
|
window.Whisper.events.trigger('unlinkAndDisconnect');
|
||||||
}
|
}
|
||||||
|
@ -171,6 +186,7 @@ export function getCI({ deviceName, backupData }: GetCIOptionsType): CIType {
|
||||||
return {
|
return {
|
||||||
deviceName,
|
deviceName,
|
||||||
backupData,
|
backupData,
|
||||||
|
isPlaintextBackup,
|
||||||
getConversationId,
|
getConversationId,
|
||||||
getMessagesBySentAt,
|
getMessagesBySentAt,
|
||||||
handleEvent,
|
handleEvent,
|
||||||
|
@ -179,6 +195,7 @@ export function getCI({ deviceName, backupData }: GetCIOptionsType): CIType {
|
||||||
waitForEvent,
|
waitForEvent,
|
||||||
openSignalRoute,
|
openSignalRoute,
|
||||||
exportBackupToDisk,
|
exportBackupToDisk,
|
||||||
|
exportPlaintextBackupToDisk,
|
||||||
unlink,
|
unlink,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -278,6 +278,14 @@ export class BackupExportStream extends Readable {
|
||||||
stats.conversations += 1;
|
stats.conversations += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pushFrame({
|
||||||
|
recipient: {
|
||||||
|
id: Long.fromNumber(this.getNextRecipientId()),
|
||||||
|
releaseNotes: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.flush();
|
||||||
|
|
||||||
const distributionLists =
|
const distributionLists =
|
||||||
await DataReader.getAllStoryDistributionsWithMembers();
|
await DataReader.getAllStoryDistributionsWithMembers();
|
||||||
|
|
||||||
|
@ -2401,7 +2409,11 @@ export class BackupExportStream extends Readable {
|
||||||
const id = Long.fromNumber(result.length);
|
const id = Long.fromNumber(result.length);
|
||||||
this.customColorIdByUuid.set(uuid, id);
|
this.customColorIdByUuid.set(uuid, id);
|
||||||
|
|
||||||
const start = hslToRGBInt(color.start.hue, color.start.saturation);
|
const start = hslToRGBInt(
|
||||||
|
color.start.hue,
|
||||||
|
color.start.saturation,
|
||||||
|
color.start.luminance
|
||||||
|
);
|
||||||
|
|
||||||
if (color.end == null) {
|
if (color.end == null) {
|
||||||
result.push({
|
result.push({
|
||||||
|
@ -2409,7 +2421,11 @@ export class BackupExportStream extends Readable {
|
||||||
solid: start,
|
solid: start,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const end = hslToRGBInt(color.end.hue, color.end.saturation);
|
const end = hslToRGBInt(
|
||||||
|
color.end.hue,
|
||||||
|
color.end.saturation,
|
||||||
|
color.end.luminance
|
||||||
|
);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
id,
|
id,
|
||||||
|
@ -2562,8 +2578,8 @@ function checkServiceIdEquivalence(
|
||||||
return leftConvo && rightConvo && leftConvo === rightConvo;
|
return leftConvo && rightConvo && leftConvo === rightConvo;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hslToRGBInt(hue: number, saturation: number): number {
|
function hslToRGBInt(hue: number, saturation: number, luminance = 1): number {
|
||||||
const { r, g, b } = hslToRGB(hue, saturation, 1);
|
const { r, g, b } = hslToRGB(hue, saturation, luminance);
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0;
|
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { DataWriter } from '../../sql/Client';
|
||||||
import type { StoryDistributionWithMembersType } from '../../sql/Interface';
|
import type { StoryDistributionWithMembersType } from '../../sql/Interface';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { GiftBadgeStates } from '../../components/conversation/Message';
|
import { GiftBadgeStates } from '../../components/conversation/Message';
|
||||||
import { StorySendMode } from '../../types/Stories';
|
import { StorySendMode, MY_STORY_ID } from '../../types/Stories';
|
||||||
import type { ServiceIdString, AciString } from '../../types/ServiceId';
|
import type { ServiceIdString, AciString } from '../../types/ServiceId';
|
||||||
import {
|
import {
|
||||||
fromAciObject,
|
fromAciObject,
|
||||||
|
@ -942,7 +942,7 @@ export class BackupImportStream extends Writable {
|
||||||
'Missing distribution list id'
|
'Missing distribution list id'
|
||||||
);
|
);
|
||||||
|
|
||||||
const id = bytesToUuid(listItem.distributionId);
|
const id = bytesToUuid(listItem.distributionId) || MY_STORY_ID;
|
||||||
strictAssert(isStoryDistributionId(id), 'Invalid distribution list id');
|
strictAssert(isStoryDistributionId(id), 'Invalid distribution list id');
|
||||||
|
|
||||||
const commonFields = {
|
const commonFields = {
|
||||||
|
@ -2987,17 +2987,20 @@ export class BackupImportStream extends Writable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rgbIntToHSL(intValue: number): { hue: number; saturation: number } {
|
function rgbIntToHSL(intValue: number): {
|
||||||
const { h: hue, s: saturation } = rgbToHSL(
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
luminance: number;
|
||||||
|
} {
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
(intValue >>> 16) & 0xff,
|
const r = (intValue >>> 16) & 0xff;
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
(intValue >>> 8) & 0xff,
|
const g = (intValue >>> 8) & 0xff;
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
intValue & 0xff
|
const b = intValue & 0xff;
|
||||||
);
|
const { h: hue, s: saturation, l: luminance } = rgbToHSL(r, g, b);
|
||||||
|
|
||||||
return { hue, saturation };
|
return { hue, saturation, luminance };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromGroupCallStateProto(
|
function fromGroupCallStateProto(
|
||||||
|
|
|
@ -24,25 +24,32 @@ import { prependStream } from '../../util/prependStream';
|
||||||
import { appendMacStream } from '../../util/appendMacStream';
|
import { appendMacStream } from '../../util/appendMacStream';
|
||||||
import { getIvAndDecipher } from '../../util/getIvAndDecipher';
|
import { getIvAndDecipher } from '../../util/getIvAndDecipher';
|
||||||
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
|
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
|
||||||
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { HOUR } from '../../util/durations';
|
import { HOUR } from '../../util/durations';
|
||||||
import { CipherType, HashType } from '../../types/Crypto';
|
import { CipherType, HashType } from '../../types/Crypto';
|
||||||
import * as Errors from '../../types/errors';
|
import * as Errors from '../../types/errors';
|
||||||
import { HTTPError } from '../../textsecure/Errors';
|
import { HTTPError } from '../../textsecure/Errors';
|
||||||
import { constantTimeEqual } from '../../Crypto';
|
import { constantTimeEqual } from '../../Crypto';
|
||||||
import { measureSize } from '../../AttachmentCrypto';
|
import { measureSize } from '../../AttachmentCrypto';
|
||||||
|
import { reinitializeRedux } from '../../state/reinitializeRedux';
|
||||||
|
import { isTestOrMockEnvironment } from '../../environment';
|
||||||
import { BackupExportStream } from './export';
|
import { BackupExportStream } from './export';
|
||||||
import { BackupImportStream } from './import';
|
import { BackupImportStream } from './import';
|
||||||
import { getKeyMaterial } from './crypto';
|
import { getKeyMaterial } from './crypto';
|
||||||
import { BackupCredentials } from './credentials';
|
import { BackupCredentials } from './credentials';
|
||||||
import { BackupAPI, type DownloadOptionsType } from './api';
|
import { BackupAPI, type DownloadOptionsType } from './api';
|
||||||
import { validateBackup } from './validator';
|
import { validateBackup } from './validator';
|
||||||
import { reinitializeRedux } from '../../state/reinitializeRedux';
|
|
||||||
import { getParametersForRedux, loadAll } from '../allLoaders';
|
import { getParametersForRedux, loadAll } from '../allLoaders';
|
||||||
|
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
|
|
||||||
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
|
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
|
||||||
|
|
||||||
|
export enum BackupType {
|
||||||
|
Ciphertext = 'Ciphertext',
|
||||||
|
TestOnlyPlaintext = 'TestOnlyPlaintext',
|
||||||
|
}
|
||||||
|
|
||||||
export class BackupsService {
|
export class BackupsService {
|
||||||
private isStarted = false;
|
private isStarted = false;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
@ -108,11 +115,18 @@ export class BackupsService {
|
||||||
// Test harness
|
// Test harness
|
||||||
public async exportToDisk(
|
public async exportToDisk(
|
||||||
path: string,
|
path: string,
|
||||||
backupLevel: BackupLevel = BackupLevel.Messages
|
backupLevel: BackupLevel = BackupLevel.Messages,
|
||||||
|
backupType = BackupType.Ciphertext
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const size = await this.exportBackup(createWriteStream(path), backupLevel);
|
const size = await this.exportBackup(
|
||||||
|
createWriteStream(path),
|
||||||
|
backupLevel,
|
||||||
|
backupType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (backupType === BackupType.Ciphertext) {
|
||||||
await validateBackup(path, size);
|
await validateBackup(path, size);
|
||||||
|
}
|
||||||
|
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
@ -184,13 +198,17 @@ export class BackupsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async importBackup(createBackupStream: () => Readable): Promise<void> {
|
public async importBackup(
|
||||||
|
createBackupStream: () => Readable,
|
||||||
|
backupType = BackupType.Ciphertext
|
||||||
|
): Promise<void> {
|
||||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||||
|
|
||||||
log.info('importBackup: starting...');
|
log.info(`importBackup: starting ${backupType}...`);
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (backupType === BackupType.Ciphertext) {
|
||||||
const { aesKey, macKey } = getKeyMaterial();
|
const { aesKey, macKey } = getKeyMaterial();
|
||||||
|
|
||||||
// First pass - don't decrypt, only verify mac
|
// First pass - don't decrypt, only verify mac
|
||||||
|
@ -231,6 +249,19 @@ export class BackupsService {
|
||||||
constantTimeEqual(hmac.digest(), theirMac),
|
constantTimeEqual(hmac.digest(), theirMac),
|
||||||
'importBackup: Bad MAC, second pass'
|
'importBackup: Bad MAC, second pass'
|
||||||
);
|
);
|
||||||
|
} else if (backupType === BackupType.TestOnlyPlaintext) {
|
||||||
|
strictAssert(
|
||||||
|
isTestOrMockEnvironment(),
|
||||||
|
'Plaintext backups can be imported only in test harness'
|
||||||
|
);
|
||||||
|
await pipeline(
|
||||||
|
createBackupStream(),
|
||||||
|
new DelimitedStream(),
|
||||||
|
new BackupImportStream()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(backupType);
|
||||||
|
}
|
||||||
|
|
||||||
await this.resetStateAfterImport();
|
await this.resetStateAfterImport();
|
||||||
|
|
||||||
|
@ -299,7 +330,8 @@ export class BackupsService {
|
||||||
|
|
||||||
private async exportBackup(
|
private async exportBackup(
|
||||||
sink: Writable,
|
sink: Writable,
|
||||||
backupLevel: BackupLevel = BackupLevel.Messages
|
backupLevel: BackupLevel = BackupLevel.Messages,
|
||||||
|
backupType = BackupType.Ciphertext
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||||
|
|
||||||
|
@ -324,6 +356,7 @@ export class BackupsService {
|
||||||
|
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
if (backupType === BackupType.Ciphertext) {
|
||||||
await pipeline(
|
await pipeline(
|
||||||
recordStream,
|
recordStream,
|
||||||
createGzip(),
|
createGzip(),
|
||||||
|
@ -336,6 +369,15 @@ export class BackupsService {
|
||||||
}),
|
}),
|
||||||
sink
|
sink
|
||||||
);
|
);
|
||||||
|
} else if (backupType === BackupType.TestOnlyPlaintext) {
|
||||||
|
strictAssert(
|
||||||
|
isTestOrMockEnvironment(),
|
||||||
|
'Plaintext backups can be exported only in test harness'
|
||||||
|
);
|
||||||
|
await pipeline(recordStream, sink);
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(backupType);
|
||||||
|
}
|
||||||
|
|
||||||
return totalBytes;
|
return totalBytes;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
61
ts/services/backups/util/FileStream.ts
Normal file
61
ts/services/backups/util/FileStream.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { type FileHandle, open } from 'node:fs/promises';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { InputStream } from '@signalapp/libsignal-client/dist/io';
|
||||||
|
|
||||||
|
export class FileStream extends InputStream {
|
||||||
|
private file: FileHandle | undefined;
|
||||||
|
private position = 0;
|
||||||
|
private buffer = Buffer.alloc(16 * 1024);
|
||||||
|
private initPromise: Promise<unknown> | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
await this.initPromise;
|
||||||
|
await this.file?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only for comparator tests
|
||||||
|
public async size(): Promise<number> {
|
||||||
|
const file = await this.lazyOpen();
|
||||||
|
const { size } = await file.stat();
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(amount: number): Promise<Buffer> {
|
||||||
|
const file = await this.lazyOpen();
|
||||||
|
if (this.buffer.length < amount) {
|
||||||
|
this.buffer = Buffer.alloc(amount);
|
||||||
|
}
|
||||||
|
const { bytesRead } = await file.read(
|
||||||
|
this.buffer,
|
||||||
|
0,
|
||||||
|
amount,
|
||||||
|
this.position
|
||||||
|
);
|
||||||
|
this.position += bytesRead;
|
||||||
|
return this.buffer.slice(0, bytesRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
async skip(amount: number): Promise<void> {
|
||||||
|
this.position += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lazyOpen(): Promise<FileHandle> {
|
||||||
|
await this.initPromise;
|
||||||
|
|
||||||
|
if (this.file) {
|
||||||
|
return this.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePromise = open(this.filePath);
|
||||||
|
this.initPromise = filePromise;
|
||||||
|
this.file = await filePromise;
|
||||||
|
return this.file;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,55 +1,12 @@
|
||||||
// Copyright 2024 Signal Messenger, LLC
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { type FileHandle, open } from 'node:fs/promises';
|
|
||||||
import * as libsignal from '@signalapp/libsignal-client/dist/MessageBackup';
|
import * as libsignal from '@signalapp/libsignal-client/dist/MessageBackup';
|
||||||
import { InputStream } from '@signalapp/libsignal-client/dist/io';
|
|
||||||
|
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { toAciObject } from '../../util/ServiceId';
|
import { toAciObject } from '../../util/ServiceId';
|
||||||
import { isTestOrMockEnvironment } from '../../environment';
|
import { isTestOrMockEnvironment } from '../../environment';
|
||||||
|
import { FileStream } from './util/FileStream';
|
||||||
class FileStream extends InputStream {
|
|
||||||
private file: FileHandle | undefined;
|
|
||||||
private position = 0;
|
|
||||||
private buffer = Buffer.alloc(16 * 1024);
|
|
||||||
private initPromise: Promise<unknown> | undefined;
|
|
||||||
|
|
||||||
constructor(private readonly filePath: string) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async close(): Promise<void> {
|
|
||||||
await this.initPromise;
|
|
||||||
await this.file?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async read(amount: number): Promise<Buffer> {
|
|
||||||
await this.initPromise;
|
|
||||||
|
|
||||||
if (!this.file) {
|
|
||||||
const filePromise = open(this.filePath);
|
|
||||||
this.initPromise = filePromise;
|
|
||||||
this.file = await filePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.buffer.length < amount) {
|
|
||||||
this.buffer = Buffer.alloc(amount);
|
|
||||||
}
|
|
||||||
const { bytesRead } = await this.file.read(
|
|
||||||
this.buffer,
|
|
||||||
0,
|
|
||||||
amount,
|
|
||||||
this.position
|
|
||||||
);
|
|
||||||
this.position += bytesRead;
|
|
||||||
return this.buffer.slice(0, bytesRead);
|
|
||||||
}
|
|
||||||
|
|
||||||
async skip(amount: number): Promise<void> {
|
|
||||||
this.position += amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateBackup(
|
export async function validateBackup(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|
|
@ -225,8 +225,13 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
||||||
|
|
||||||
let deviceName: string;
|
let deviceName: string;
|
||||||
let backupFileData: Uint8Array | undefined;
|
let backupFileData: Uint8Array | undefined;
|
||||||
|
let isPlaintextBackup = false;
|
||||||
if (window.SignalCI) {
|
if (window.SignalCI) {
|
||||||
({ deviceName, backupData: backupFileData } = window.SignalCI);
|
({
|
||||||
|
deviceName,
|
||||||
|
backupData: backupFileData,
|
||||||
|
isPlaintextBackup = false,
|
||||||
|
} = window.SignalCI);
|
||||||
} else {
|
} else {
|
||||||
deviceName = await chooseDeviceNamePromiseWrapperRef.current.promise;
|
deviceName = await chooseDeviceNamePromiseWrapperRef.current.promise;
|
||||||
const backupFile =
|
const backupFile =
|
||||||
|
@ -264,6 +269,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
||||||
const data = provisioner.prepareLinkData({
|
const data = provisioner.prepareLinkData({
|
||||||
deviceName,
|
deviceName,
|
||||||
backupFile: backupFileData,
|
backupFile: backupFileData,
|
||||||
|
isPlaintextBackup,
|
||||||
});
|
});
|
||||||
await accountManager.registerSecondDevice(data);
|
await accountManager.registerSecondDevice(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
86
ts/test-mock/backups/integration_test.ts
Normal file
86
ts/test-mock/backups/integration_test.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import createDebug from 'debug';
|
||||||
|
import fastGlob from 'fast-glob';
|
||||||
|
import {
|
||||||
|
ComparableBackup,
|
||||||
|
Purpose,
|
||||||
|
} from '@signalapp/libsignal-client/dist/MessageBackup';
|
||||||
|
|
||||||
|
import * as durations from '../../util/durations';
|
||||||
|
import { FileStream } from '../../services/backups/util/FileStream';
|
||||||
|
import type { App } from '../playwright';
|
||||||
|
import { Bootstrap } from '../bootstrap';
|
||||||
|
|
||||||
|
export const debug = createDebug('mock:test:backups');
|
||||||
|
|
||||||
|
const TEST_FOLDER = process.env.BACKUP_TEST_FOLDER;
|
||||||
|
|
||||||
|
describe('backups/integration', async function (this: Mocha.Suite) {
|
||||||
|
this.timeout(100 * durations.MINUTE);
|
||||||
|
|
||||||
|
if (!TEST_FOLDER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bootstrap: Bootstrap;
|
||||||
|
let app: App | undefined;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bootstrap = new Bootstrap();
|
||||||
|
await bootstrap.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function (this: Mocha.Context) {
|
||||||
|
if (!bootstrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||||
|
await app?.close();
|
||||||
|
await bootstrap.teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
const testFiles = fastGlob.sync(join(TEST_FOLDER, '*.binproto'), {
|
||||||
|
onlyFiles: true,
|
||||||
|
});
|
||||||
|
testFiles.forEach(fullPath => {
|
||||||
|
it(`passes ${fullPath}`, async () => {
|
||||||
|
app = await bootstrap.link({
|
||||||
|
ciBackupPath: fullPath,
|
||||||
|
ciIsPlaintextBackup: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const backupPath = bootstrap.getBackupPath('backup.bin');
|
||||||
|
await app.exportPlaintextBackupToDisk(backupPath);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
const actualStream = new FileStream(backupPath);
|
||||||
|
const expectedStream = new FileStream(fullPath);
|
||||||
|
try {
|
||||||
|
const actual = await ComparableBackup.fromUnencrypted(
|
||||||
|
Purpose.RemoteBackup,
|
||||||
|
actualStream,
|
||||||
|
BigInt(await actualStream.size())
|
||||||
|
);
|
||||||
|
const expected = await ComparableBackup.fromUnencrypted(
|
||||||
|
Purpose.RemoteBackup,
|
||||||
|
expectedStream,
|
||||||
|
BigInt(await expectedStream.size())
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
actual.comparableString(),
|
||||||
|
expected.comparableString()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await actualStream.close();
|
||||||
|
await expectedStream.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -179,6 +179,13 @@ export class App extends EventEmitter {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async exportPlaintextBackupToDisk(path: string): Promise<Uint8Array> {
|
||||||
|
const window = await this.getWindow();
|
||||||
|
return window.evaluate(
|
||||||
|
`window.SignalCI.exportPlaintextBackupToDisk(${JSON.stringify(path)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async unlink(): Promise<void> {
|
public async unlink(): Promise<void> {
|
||||||
const window = await this.getWindow();
|
const window = await this.getWindow();
|
||||||
return window.evaluate('window.SignalCI.unlink()');
|
return window.evaluate('window.SignalCI.unlink()');
|
||||||
|
|
|
@ -25,7 +25,7 @@ import createTaskWithTimeout from './TaskWithTimeout';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import { senderCertificateService } from '../services/senderCertificate';
|
import { senderCertificateService } from '../services/senderCertificate';
|
||||||
import { backupsService } from '../services/backups';
|
import { backupsService, BackupType } from '../services/backups';
|
||||||
import {
|
import {
|
||||||
decryptDeviceName,
|
decryptDeviceName,
|
||||||
deriveAccessKey,
|
deriveAccessKey,
|
||||||
|
@ -123,7 +123,10 @@ type CreateAccountSharedOptionsType = Readonly<{
|
||||||
pniKeyPair: KeyPairType;
|
pniKeyPair: KeyPairType;
|
||||||
profileKey: Uint8Array;
|
profileKey: Uint8Array;
|
||||||
masterKey: Uint8Array;
|
masterKey: Uint8Array;
|
||||||
|
|
||||||
|
// Test-only
|
||||||
backupFile?: Uint8Array;
|
backupFile?: Uint8Array;
|
||||||
|
isPlaintextBackup?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type CreatePrimaryDeviceOptionsType = Readonly<{
|
type CreatePrimaryDeviceOptionsType = Readonly<{
|
||||||
|
@ -217,6 +220,7 @@ function signedPreKeyToUploadSignedPreKey({
|
||||||
export type ConfirmNumberResultType = Readonly<{
|
export type ConfirmNumberResultType = Readonly<{
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
backupFile: Uint8Array | undefined;
|
backupFile: Uint8Array | undefined;
|
||||||
|
isPlaintextBackup: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export default class AccountManager extends EventTarget {
|
export default class AccountManager extends EventTarget {
|
||||||
|
@ -919,6 +923,7 @@ export default class AccountManager extends EventTarget {
|
||||||
readReceipts,
|
readReceipts,
|
||||||
userAgent,
|
userAgent,
|
||||||
backupFile,
|
backupFile,
|
||||||
|
isPlaintextBackup,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const { storage } = window.textsecure;
|
const { storage } = window.textsecure;
|
||||||
|
@ -963,7 +968,9 @@ export default class AccountManager extends EventTarget {
|
||||||
}
|
}
|
||||||
if (backupFile !== undefined) {
|
if (backupFile !== undefined) {
|
||||||
log.warn(
|
log.warn(
|
||||||
'createAccount: Restoring from backup; deleting all previous data'
|
'createAccount: Restoring from ' +
|
||||||
|
`${isPlaintextBackup ? 'plaintext' : 'ciphertext'} backup; ` +
|
||||||
|
'deleting all previous data'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1222,7 +1229,10 @@ export default class AccountManager extends EventTarget {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (backupFile !== undefined) {
|
if (backupFile !== undefined) {
|
||||||
await backupsService.importBackup(() => Readable.from([backupFile]));
|
await backupsService.importBackup(
|
||||||
|
() => Readable.from([backupFile]),
|
||||||
|
isPlaintextBackup ? BackupType.TestOnlyPlaintext : BackupType.Ciphertext
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -67,6 +67,7 @@ type StateType = Readonly<
|
||||||
export type PrepareLinkDataOptionsType = Readonly<{
|
export type PrepareLinkDataOptionsType = Readonly<{
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
backupFile?: Uint8Array;
|
backupFile?: Uint8Array;
|
||||||
|
isPlaintextBackup?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export class Provisioner {
|
export class Provisioner {
|
||||||
|
@ -150,6 +151,7 @@ export class Provisioner {
|
||||||
public prepareLinkData({
|
public prepareLinkData({
|
||||||
deviceName,
|
deviceName,
|
||||||
backupFile,
|
backupFile,
|
||||||
|
isPlaintextBackup,
|
||||||
}: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType {
|
}: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType {
|
||||||
strictAssert(
|
strictAssert(
|
||||||
this.state.step === Step.ReadyToLink,
|
this.state.step === Step.ReadyToLink,
|
||||||
|
@ -204,6 +206,7 @@ export class Provisioner {
|
||||||
profileKey,
|
profileKey,
|
||||||
deviceName,
|
deviceName,
|
||||||
backupFile,
|
backupFile,
|
||||||
|
isPlaintextBackup,
|
||||||
userAgent,
|
userAgent,
|
||||||
ourAci,
|
ourAci,
|
||||||
ourPni,
|
ourPni,
|
||||||
|
|
|
@ -159,8 +159,8 @@ export const ContactNameColors = [
|
||||||
export type ContactNameColorType = (typeof ContactNameColors)[number];
|
export type ContactNameColorType = (typeof ContactNameColors)[number];
|
||||||
|
|
||||||
export type CustomColorType = {
|
export type CustomColorType = {
|
||||||
start: { hue: number; saturation: number };
|
start: { hue: number; saturation: number; luminance?: number };
|
||||||
end?: { hue: number; saturation: number };
|
end?: { hue: number; saturation: number; luminance?: number };
|
||||||
deg?: number;
|
deg?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,7 @@ export const rendererConfigSchema = z.object({
|
||||||
disableIPv6: z.boolean(),
|
disableIPv6: z.boolean(),
|
||||||
dnsFallback: DNSFallbackSchema,
|
dnsFallback: DNSFallbackSchema,
|
||||||
ciBackupPath: configOptionalStringSchema,
|
ciBackupPath: configOptionalStringSchema,
|
||||||
|
ciIsPlaintextBackup: z.boolean(),
|
||||||
environment: environmentSchema,
|
environment: environmentSchema,
|
||||||
isMockTestEnvironment: z.boolean(),
|
isMockTestEnvironment: z.boolean(),
|
||||||
homePath: configRequiredStringSchema,
|
homePath: configRequiredStringSchema,
|
||||||
|
|
|
@ -46,14 +46,17 @@ export function getHSL(
|
||||||
{
|
{
|
||||||
hue,
|
hue,
|
||||||
saturation,
|
saturation,
|
||||||
|
luminance,
|
||||||
}: {
|
}: {
|
||||||
hue: number;
|
hue: number;
|
||||||
saturation: number;
|
saturation: number;
|
||||||
|
luminance?: number;
|
||||||
},
|
},
|
||||||
adjustedLightness = 0
|
adjustedLightness = 0
|
||||||
): string {
|
): string {
|
||||||
return `hsl(${hue}, ${saturation}%, ${adjustLightnessValue(
|
return `hsl(${hue}, ${saturation}%, ${
|
||||||
calculateLightness(hue),
|
luminance == null
|
||||||
adjustedLightness
|
? adjustLightnessValue(calculateLightness(hue), adjustedLightness)
|
||||||
)}%)`;
|
: luminance * 100
|
||||||
|
}%)`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,5 +25,6 @@ if (config.ciMode) {
|
||||||
backupData: config.ciBackupPath
|
backupData: config.ciBackupPath
|
||||||
? fs.readFileSync(config.ciBackupPath)
|
? fs.readFileSync(config.ciBackupPath)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
isPlaintextBackup: config.ciIsPlaintextBackup === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue