Export/import attachments in integration tests

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-10-15 15:15:05 -05:00 committed by GitHub
parent 850b78042b
commit 6859b1a220
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 292 additions and 116 deletions

View file

@ -4699,6 +4699,10 @@
"messageformat": "Downloading {currentSize} of {totalSize} ({fractionComplete, number, percent})...",
"description": "Hint under the progressbar in the backup import screen"
},
"icu:BackupImportScreen__progressbar-hint--processing": {
"messageformat": "Finalizing message transfer...",
"description": "Hint under the progressbar in the backup import screen shown after the backup file is downloaded and while backup data is being processed"
},
"icu:BackupImportScreen__progressbar-hint--preparing": {
"messageformat": "Preparing to download...",
"description": "Hint under the progressbar in the backup import screen when download size is not yet known"

8
package-lock.json generated
View file

@ -126,7 +126,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.1",
"@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "8.0.1",
"@signalapp/mock-server": "8.1.1",
"@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
@ -7296,9 +7296,9 @@
}
},
"node_modules/@signalapp/mock-server": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.0.1.tgz",
"integrity": "sha512-qfyBOtMmQ3RF3Kig0DTafrxUx8MZ2hB+5H6ZJVV1lQS022U6bOHiVjZyAJ0uZgU98FJZIXlT/zWJ24kFl6/pGQ==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.1.1.tgz",
"integrity": "sha512-TlQpOyUYnDBV7boxyaLDaeGTN5WIn4trbF+9rq4+6rXfpzIBnf2A4Y1fzFRVL9F9/F4ZEPtrv+V3oplNrfoZ9w==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {

View file

@ -210,7 +210,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.1",
"@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "8.0.1",
"@signalapp/mock-server": "8.1.1",
"@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",

View file

@ -110,17 +110,22 @@ export type HardcodedIVForEncryptionType =
digestToMatch: Uint8Array;
};
type EncryptAttachmentV2PropsType = {
type EncryptAttachmentV2OptionsType = Readonly<{
dangerousIv?: HardcodedIVForEncryptionType;
dangerousTestOnlySkipPadding?: boolean;
getAbsoluteAttachmentPath: (relativePath: string) => string;
keys: Readonly<Uint8Array>;
needIncrementalMac: boolean;
plaintext: PlaintextSourceType;
};
}>;
export type EncryptAttachmentV2ToDiskOptionsType =
EncryptAttachmentV2OptionsType &
Readonly<{
getAbsoluteAttachmentPath: (relativePath: string) => string;
}>;
export async function encryptAttachmentV2ToDisk(
args: EncryptAttachmentV2PropsType
args: EncryptAttachmentV2ToDiskOptionsType
): Promise<EncryptedAttachmentV2 & { path: string }> {
// Create random output file
const relativeTargetPath = getRelativePath(createName());
@ -152,7 +157,7 @@ export async function encryptAttachmentV2({
needIncrementalMac,
plaintext,
sink,
}: EncryptAttachmentV2PropsType & {
}: EncryptAttachmentV2OptionsType & {
sink?: Writable;
}): Promise<EncryptedAttachmentV2> {
const logId = 'encryptAttachmentV2';
@ -580,7 +585,6 @@ export async function decryptAndReencryptLocally(
const [result] = await Promise.all([
decryptAttachmentV2ToSink(options, passthrough),
await encryptAttachmentV2({
getAbsoluteAttachmentPath: options.getAbsoluteAttachmentPath,
keys,
needIncrementalMac: false,
plaintext: {

View file

@ -10,6 +10,7 @@ import * as log from './logging/log';
import { explodePromise } from './util/explodePromise';
import { AccessType, ipcInvoke } from './sql/channels';
import { backupsService } from './services/backups';
import { AttachmentBackupManager } from './jobs/AttachmentBackupManager';
import { SECOND } from './util/durations';
import { isSignalRoute } from './util/signalRoutes';
import { strictAssert } from './util/assert';
@ -168,6 +169,7 @@ export function getCI({ deviceName }: GetCIOptionsType): CIType {
async function uploadBackup() {
await backupsService.upload();
await AttachmentBackupManager.waitForIdle();
}
function unlink() {

View file

@ -1604,8 +1604,9 @@ export async function startApp(): Promise<void> {
// Download backup before enabling request handler and storage service
try {
await backupsService.download({
onProgress: (currentBytes, totalBytes) => {
onProgress: (backupStep, currentBytes, totalBytes) => {
window.reduxActions.installer.updateBackupImportProgress({
backupStep,
currentBytes,
totalBytes,
});

View file

@ -5,6 +5,7 @@ import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
import enMessages from '../../../_locales/en/messages.json';
import type { PropsType } from './InstallScreenBackupImportStep';
import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep';
@ -27,25 +28,36 @@ const Template: StoryFn<PropsType> = (args: PropsType) => (
export const NoBytes = Template.bind({});
NoBytes.args = {
backupStep: InstallScreenBackupStep.Download,
currentBytes: undefined,
totalBytes: undefined,
};
export const Bytes = Template.bind({});
Bytes.args = {
backupStep: InstallScreenBackupStep.Download,
currentBytes: 500 * 1024,
totalBytes: 1024 * 1024,
};
export const Full = Template.bind({});
Full.args = {
backupStep: InstallScreenBackupStep.Download,
currentBytes: 1024,
totalBytes: 1024,
};
export const Error = Template.bind({});
Error.args = {
backupStep: InstallScreenBackupStep.Download,
currentBytes: 500 * 1024,
totalBytes: 1024 * 1024,
hasError: true,
};
export const Processing = Template.bind({});
Processing.args = {
backupStep: InstallScreenBackupStep.Process,
currentBytes: 500 * 1024,
totalBytes: 1024 * 1024,
};

View file

@ -4,18 +4,21 @@
import React, { useState, useCallback } from 'react';
import type { LocalizerType } from '../../types/Util';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
import { formatFileSize } from '../../util/formatFileSize';
import { TitlebarDragArea } from '../TitlebarDragArea';
import { ProgressBar } from '../ProgressBar';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
import { roundFractionForProgressBar } from '../../util/numbers';
import { missingCaseError } from '../../util/missingCaseError';
// We can't always use destructuring assignment because of the complexity of this props
// type.
export type PropsType = Readonly<{
i18n: LocalizerType;
backupStep: InstallScreenBackupStep;
currentBytes?: number;
totalBytes?: number;
hasError?: boolean;
@ -25,6 +28,7 @@ export type PropsType = Readonly<{
export function InstallScreenBackupImportStep({
i18n,
backupStep,
currentBytes,
totalBytes,
hasError,
@ -66,26 +70,33 @@ export function InstallScreenBackupImportStep({
}, [onRetry]);
let progress: JSX.Element;
let isCancelPossible = true;
if (currentBytes != null && totalBytes != null) {
isCancelPossible = currentBytes !== totalBytes;
const fractionComplete = roundFractionForProgressBar(
currentBytes / totalBytes
);
let hint: string;
if (backupStep === InstallScreenBackupStep.Download) {
hint = i18n('icu:BackupImportScreen__progressbar-hint', {
currentSize: formatFileSize(currentBytes),
totalSize: formatFileSize(totalBytes),
fractionComplete,
});
} else if (backupStep === InstallScreenBackupStep.Process) {
hint = i18n('icu:BackupImportScreen__progressbar-hint--processing');
} else {
throw missingCaseError(backupStep);
}
progress = (
<>
<ProgressBar
key={backupStep}
fractionComplete={fractionComplete}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
<div className="InstallScreenBackupImportStep__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint', {
currentSize: formatFileSize(currentBytes),
totalSize: formatFileSize(totalBytes),
fractionComplete,
})}
{hint}
</div>
</>
);
@ -93,6 +104,7 @@ export function InstallScreenBackupImportStep({
progress = (
<>
<ProgressBar
key={backupStep}
fractionComplete={0}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
@ -119,7 +131,7 @@ export function InstallScreenBackupImportStep({
</div>
</div>
{isCancelPossible && (
{backupStep === InstallScreenBackupStep.Download && (
<button
className="InstallScreenBackupImportStep__cancel"
type="button"

View file

@ -159,6 +159,10 @@ export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobT
static async addJob(newJob: CoreAttachmentBackupJobType): Promise<void> {
return AttachmentBackupManager.instance.addJob(newJob);
}
static async waitForIdle(): Promise<void> {
return AttachmentBackupManager.instance.waitForIdle();
}
}
function getJobId(job: CoreAttachmentBackupJobType): string {

View file

@ -77,6 +77,7 @@ export abstract class JobManager<CoreJobType> {
private jobCompletePromises: Map<string, ExplodePromiseResultType<void>> =
new Map();
private tickTimeout: NodeJS.Timeout | null = null;
private idleCallbacks = new Array<() => void>();
protected logPrefix = 'JobManager';
public tickInterval = DEFAULT_TICK_INTERVAL;
@ -106,6 +107,14 @@ export abstract class JobManager<CoreJobType> {
);
}
async waitForIdle(): Promise<void> {
if (this.activeJobs.size === 0) {
return;
}
await new Promise<void>(resolve => this.idleCallbacks.push(resolve));
}
private tick(): void {
clearTimeoutIfNecessary(this.tickTimeout);
this.tickTimeout = null;
@ -233,6 +242,13 @@ export abstract class JobManager<CoreJobType> {
});
if (nextJobs.length === 0) {
if (this.idleCallbacks.length > 0) {
const callbacks = this.idleCallbacks;
this.idleCallbacks = [];
for (const callback of callbacks) {
callback();
}
}
return;
}

View file

@ -27,6 +27,7 @@ import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
import { missingCaseError } from '../../util/missingCaseError';
import { HOUR } from '../../util/durations';
import { CipherType, HashType } from '../../types/Crypto';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
import * as Errors from '../../types/errors';
import { HTTPError } from '../../textsecure/Errors';
import { constantTimeEqual } from '../../Crypto';
@ -36,7 +37,7 @@ import { BackupExportStream } from './export';
import { BackupImportStream } from './import';
import { getKeyMaterial } from './crypto';
import { BackupCredentials } from './credentials';
import { BackupAPI, type DownloadOptionsType } from './api';
import { BackupAPI } from './api';
import { validateBackup } from './validator';
import { BackupType } from './types';
import type { ExplodePromiseResultType } from '../../util/explodePromise';
@ -49,6 +50,20 @@ const IV_LENGTH = 16;
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
export type DownloadOptionsType = Readonly<{
onProgress?: (
backupStep: InstallScreenBackupStep,
currentBytes: number,
totalBytes: number
) => void;
abortSignal?: AbortSignal;
}>;
export type ImportOptionsType = Readonly<{
backupType?: BackupType;
onProgress?: (currentBytes: number, totalBytes: number) => void;
}>;
export class BackupsService {
private isStarted = false;
private isRunning = false;
@ -82,9 +97,7 @@ export class BackupsService {
});
}
public async download(
options: Omit<DownloadOptionsType, 'downloadOffset'>
): Promise<void> {
public async download(options: DownloadOptionsType): Promise<void> {
const backupDownloadPath = window.storage.get('backupDownloadPath');
if (!backupDownloadPath) {
log.warn('backups.download: no backup download path, skipping');
@ -102,7 +115,10 @@ export class BackupsService {
// eslint-disable-next-line no-await-in-loop
hasBackup = await this.doDownload(absoluteDownloadPath, options);
} catch (error) {
log.info('backups.download: error, prompting user to retry');
log.warn(
'backups.download: error, prompting user to retry',
Errors.toLogFormat(error)
);
this.downloadRetryPromise = explodePromise<RetryBackupImportValue>();
window.reduxActions.installer.updateBackupImportProgress({
hasError: true,
@ -202,8 +218,11 @@ export class BackupsService {
});
}
public async importFromDisk(backupFile: string): Promise<void> {
return backupsService.importBackup(() => createReadStream(backupFile));
public async importFromDisk(
backupFile: string,
options?: ImportOptionsType
): Promise<void> {
return this.importBackup(() => createReadStream(backupFile), options);
}
public cancelDownload(): void {
@ -221,7 +240,7 @@ export class BackupsService {
public async importBackup(
createBackupStream: () => Readable,
backupType = BackupType.Ciphertext
{ backupType = BackupType.Ciphertext, onProgress }: ImportOptionsType = {}
): Promise<void> {
strictAssert(!this.isRunning, 'BackupService is already running');
@ -236,8 +255,12 @@ export class BackupsService {
// First pass - don't decrypt, only verify mac
let hmac = createHmac(HashType.size256, macKey);
let theirMac: Uint8Array | undefined;
let totalBytes = 0;
const sink = new PassThrough();
sink.on('data', chunk => {
totalBytes += chunk.byteLength;
});
// Discard the data in the first pass
sink.resume();
@ -249,6 +272,8 @@ export class BackupsService {
sink
);
onProgress?.(0, totalBytes);
strictAssert(theirMac != null, 'importBackup: Missing MAC');
strictAssert(
constantTimeEqual(hmac.digest(), theirMac),
@ -258,9 +283,19 @@ export class BackupsService {
// Second pass - decrypt (but still check the mac at the end)
hmac = createHmac(HashType.size256, macKey);
const progressReporter = new PassThrough();
progressReporter.pause();
let currentBytes = 0;
progressReporter.on('data', chunk => {
currentBytes += chunk.byteLength;
onProgress?.(currentBytes, totalBytes);
});
await pipeline(
createBackupStream(),
getMacAndUpdateHmac(hmac, noop),
progressReporter,
getIvAndDecipher(aesKey),
createGunzip(),
new DelimitedStream(),
@ -343,7 +378,7 @@ export class BackupsService {
private async doDownload(
downloadPath: string,
{ onProgress }: Omit<DownloadOptionsType, 'downloadOffset'>
{ onProgress }: Pick<DownloadOptionsType, 'onProgress'>
): Promise<boolean> {
const controller = new AbortController();
@ -371,7 +406,13 @@ export class BackupsService {
const stream = await this.api.download({
downloadOffset,
onProgress,
onProgress: (currentBytes, totalBytes) => {
onProgress?.(
InstallScreenBackupStep.Download,
currentBytes,
totalBytes
);
},
abortSignal: controller.signal,
});
@ -395,7 +436,15 @@ export class BackupsService {
// Too late to cancel now
try {
await this.importFromDisk(downloadPath);
await this.importFromDisk(downloadPath, {
onProgress: (currentBytes, totalBytes) => {
onProgress?.(
InstallScreenBackupStep.Process,
currentBytes,
totalBytes
);
},
});
} finally {
await unlink(downloadPath);
}

View file

@ -7,6 +7,7 @@ import pTimeout, { TimeoutError } from 'p-timeout';
import type { StateType as RootStateType } from '../reducer';
import {
InstallScreenBackupStep,
InstallScreenStep,
InstallScreenError,
InstallScreenQRCodeError,
@ -62,6 +63,7 @@ export type InstallerStateType = ReadonlyDeep<
}
| {
step: InstallScreenStep.BackupImport;
backupStep: InstallScreenBackupStep;
currentBytes?: number;
totalBytes?: number;
hasError?: boolean;
@ -124,6 +126,7 @@ type UpdateBackupImportProgressActionType = ReadonlyDeep<{
type: typeof UPDATE_BACKUP_IMPORT_PROGRESS;
payload:
| {
backupStep: InstallScreenBackupStep;
currentBytes: number;
totalBytes: number;
}
@ -560,6 +563,7 @@ export function reducer(
return {
step: InstallScreenStep.BackupImport,
backupStep: InstallScreenBackupStep.Download,
};
}
@ -581,6 +585,7 @@ export function reducer(
return {
...state,
backupStep: action.payload.backupStep,
currentBytes: action.payload.currentBytes,
totalBytes: action.payload.totalBytes,
};

View file

@ -109,6 +109,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
step: InstallScreenStep.BackupImport,
screenSpecificProps: {
i18n,
backupStep: installerState.backupStep,
currentBytes: installerState.currentBytes,
totalBytes: installerState.totalBytes,
hasError: installerState.hasError,

View file

@ -72,10 +72,9 @@ describe('backup/integration', () => {
it(basename(fullPath), async () => {
const expectedBuffer = await readFile(fullPath);
await backupsService.importBackup(
() => Readable.from([expectedBuffer]),
BackupType.TestOnlyPlaintext
);
await backupsService.importBackup(() => Readable.from([expectedBuffer]), {
backupType: BackupType.TestOnlyPlaintext,
});
const exported = await backupsService.exportBackupData(
BackupLevel.Media,

View file

@ -109,7 +109,6 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
keys: Bytes.fromBase64(LOCAL_ENCRYPTION_KEYS),
needIncrementalMac: false,
sink: createWriteStream(absolutePath),
getAbsoluteAttachmentPath,
});
});

View file

@ -38,8 +38,6 @@ describe('utils/ensureAttachmentIsReencryptable', async () => {
plaintext: {
absolutePath: plaintextFilePath,
},
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
needIncrementalMac: false,
});
digest = encrypted.digest;

View file

@ -1,6 +1,8 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import createDebug from 'debug';
import Long from 'long';
import { Proto, StorageState } from '@signalapp/mock-server';
@ -8,10 +10,16 @@ import { expect } from 'playwright/test';
import { generateStoryDistributionId } from '../../types/StoryDistributionId';
import { MY_STORY_ID } from '../../types/Stories';
import { IMAGE_JPEG } from '../../types/MIME';
import { uuidToBytes } from '../../util/uuidToBytes';
import * as durations from '../../util/durations';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import {
getMessageInTimelineByTimestamp,
sendTextMessage,
sendReaction,
} from '../helpers';
export const debug = createDebug('mock:test:backups');
@ -19,6 +27,15 @@ const IdentifierType = Proto.ManifestRecord.Identifier.Type;
const DISTRIBUTION1 = generateStoryDistributionId();
const CAT_PATH = join(
__dirname,
'..',
'..',
'..',
'fixtures',
'cat-screenshot.png'
);
describe('backups', function (this: Mocha.Suite) {
this.timeout(100 * durations.MINUTE);
@ -102,9 +119,10 @@ describe('backups', function (this: Mocha.Suite) {
const [friend, pinned] = contacts;
{
debug('wait for storage service sync to finish');
const window = await app.getWindow();
debug('wait for storage service sync to finish');
const leftPane = window.locator('#LeftPane');
const contact = leftPane.locator(
`[data-testid="${pinned.device.aci}"] >> "${pinned.profileName}"`
@ -137,55 +155,85 @@ describe('backups', function (this: Mocha.Suite) {
await backButton.last().click();
}
const sends = new Array<Promise<void>>();
for (let i = 0; i < 5; i += 1) {
// eslint-disable-next-line no-await-in-loop
await server.send(
desktop,
// eslint-disable-next-line no-await-in-loop
await phone.encryptSyncSent(desktop, `to pinned ${i}`, {
sends.push(
sendTextMessage({
from: phone,
to: pinned,
text: `to pinned ${i}`,
desktop,
timestamp: bootstrap.getTimestamp(),
destinationServiceId: pinned.device.aci,
})
);
const theirTimestamp = bootstrap.getTimestamp();
// eslint-disable-next-line no-await-in-loop
await friend.sendText(desktop, `msg ${i}`, {
timestamp: theirTimestamp,
});
sends.push(
sendTextMessage({
from: friend,
to: desktop,
text: `msg ${i}`,
desktop,
timestamp: theirTimestamp,
})
);
const ourTimestamp = bootstrap.getTimestamp();
// eslint-disable-next-line no-await-in-loop
await server.send(
desktop,
// eslint-disable-next-line no-await-in-loop
await phone.encryptSyncSent(desktop, `respond ${i}`, {
sends.push(
sendTextMessage({
from: phone,
to: friend,
text: `respond ${i}`,
desktop,
timestamp: ourTimestamp,
destinationServiceId: friend.device.aci,
})
);
const reactionTimestamp = bootstrap.getTimestamp();
sends.push(
sendReaction({
from: friend,
to: desktop,
targetAuthor: desktop,
targetMessageTimestamp: ourTimestamp,
reactionTimestamp,
desktop,
emoji: '👍',
})
);
}
// eslint-disable-next-line no-await-in-loop
await friend.sendRaw(
const catTimestamp = bootstrap.getTimestamp();
const plaintextCat = await readFile(CAT_PATH);
const ciphertextCat = await bootstrap.storeAttachmentOnCDN(
plaintextCat,
IMAGE_JPEG
);
sends.push(
pinned.sendRaw(
desktop,
{
dataMessage: {
timestamp: Long.fromNumber(reactionTimestamp),
reaction: {
emoji: '👍',
targetAuthorAci: desktop.aci,
targetTimestamp: Long.fromNumber(ourTimestamp),
},
timestamp: Long.fromNumber(catTimestamp),
attachments: [ciphertextCat],
},
},
{
timestamp: reactionTimestamp,
timestamp: catTimestamp,
}
);
)
);
await Promise.all(sends);
{
const window = await app.getWindow();
await getMessageInTimelineByTimestamp(window, catTimestamp)
.locator('img')
.waitFor();
}
await app.uploadBackup();
@ -195,7 +243,7 @@ describe('backups', function (this: Mocha.Suite) {
async (window, snapshot) => {
const leftPane = window.locator('#LeftPane');
const pinnedElem = leftPane.locator(
`[data-testid="${pinned.toContact().aci}"] >> "to pinned 4"`
`[data-testid="${pinned.toContact().aci}"] >> "Photo"`
);
debug('Waiting for messages to pinned contact to come through');
@ -246,6 +294,7 @@ describe('backups', function (this: Mocha.Suite) {
// Restart
await bootstrap.eraseStorage();
await server.removeAllCDNAttachments();
app = await bootstrap.link();
await app.waitForBackupImportComplete();
@ -253,6 +302,14 @@ describe('backups', function (this: Mocha.Suite) {
// app won't show contacts as "system"
await app.waitForContactSync();
debug('Waiting for attachments to be downloaded');
{
const window = await app.getWindow();
await window
.locator('.BackupMediaDownloadProgress__button-close')
.click();
}
await comparator(app);
});
});

View file

@ -6,14 +6,16 @@ import fs from 'fs/promises';
import crypto from 'crypto';
import path, { join } from 'path';
import os from 'os';
import { PassThrough } from 'node:stream';
import createDebug from 'debug';
import pTimeout from 'p-timeout';
import normalizePath from 'normalize-path';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
import type { Page } from 'playwright';
import { v4 as uuid } from 'uuid';
import type { Device, PrimaryDevice } from '@signalapp/mock-server';
import type { Device, PrimaryDevice, Proto } from '@signalapp/mock-server';
import {
Server,
ServiceIdKind,
@ -23,9 +25,14 @@ import { MAX_READ_KEYS as MAX_STORAGE_READ_KEYS } from '../services/storageConst
import * as durations from '../util/durations';
import { drop } from '../util/drop';
import type { RendererConfigType } from '../types/RendererConfig';
import type { MIMEType } from '../types/MIME';
import { App } from './playwright';
import { CONTACT_COUNT } from './benchmarks/fixtures';
import { strictAssert } from '../util/assert';
import {
encryptAttachmentV2,
generateAttachmentKeys,
} from '../AttachmentCrypto';
export { App };
@ -540,6 +547,38 @@ export class Bootstrap {
return join(this.storagePath, 'attachments.noindex', relativePath);
}
public async storeAttachmentOnCDN(
data: Buffer,
contentType: MIMEType
): Promise<Proto.IAttachmentPointer> {
const cdnKey = uuid();
const keys = generateAttachmentKeys();
const cdnNumber = 3;
const passthrough = new PassThrough();
const [{ digest }] = await Promise.all([
encryptAttachmentV2({
keys,
plaintext: {
data,
},
needIncrementalMac: false,
sink: passthrough,
}),
this.server.storeAttachmentOnCdn(cdnNumber, cdnKey, passthrough),
]);
return {
size: data.byteLength,
contentType,
cdnKey,
cdnNumber,
key: keys,
digest,
};
}
//
// Getters
//

View file

@ -4,7 +4,7 @@
import createDebug from 'debug';
import { assert } from 'chai';
import { expect } from 'playwright/test';
import { readFileSync } from 'fs';
import { readFile } from 'node:fs/promises';
import { type PrimaryDevice, StorageState } from '@signalapp/mock-server';
import * as path from 'path';
import type { App } from '../playwright';
@ -17,10 +17,6 @@ import {
} from '../helpers';
import * as durations from '../../util/durations';
import { strictAssert } from '../../util/assert';
import {
encryptAttachmentV2ToDisk,
generateAttachmentKeys,
} from '../../AttachmentCrypto';
import { toBase64 } from '../../Bytes';
import type { AttachmentWithNewReencryptionInfoType } from '../../types/Attachment';
import { IMAGE_JPEG } from '../../types/MIME';
@ -144,34 +140,13 @@ describe('attachments', function (this: Mocha.Suite) {
await page.getByTestId(pinned.device.aci).click();
const plaintextCat = readFileSync(CAT_PATH);
const cdnKey = 'cdnKey';
const keys = generateAttachmentKeys();
const cdnNumber = 3;
const { digest: newDigest, path: ciphertextPath } =
await encryptAttachmentV2ToDisk({
keys,
plaintext: {
// add non-zero byte to the end of the data; this will be considered padding
// when received since we will include the size of the un-appended data when
// sending
data: Buffer.concat([plaintextCat, Buffer.from([1])]),
},
getAbsoluteAttachmentPath: relativePath =>
bootstrap.getAbsoluteAttachmentPath(relativePath),
needIncrementalMac: false,
});
const ciphertextCatWithNonZeroPadding = readFileSync(
bootstrap.getAbsoluteAttachmentPath(ciphertextPath)
);
await bootstrap.server.storeAttachmentOnCdn(
cdnNumber,
cdnKey,
ciphertextCatWithNonZeroPadding
const plaintextCat = await readFile(CAT_PATH);
const attachment = await bootstrap.storeAttachmentOnCDN(
// add non-zero byte to the end of the data; this will be considered padding
// when received since we will include the size of the un-appended data when
// sending
Buffer.concat([plaintextCat, Buffer.from([1])]),
IMAGE_JPEG
);
const incomingTimestamp = Date.now();
@ -182,12 +157,8 @@ describe('attachments', function (this: Mocha.Suite) {
text: 'Wait, that is MY cat! But now with weird padding!',
attachments: [
{
...attachment,
size: plaintextCat.byteLength,
contentType: IMAGE_JPEG,
cdnKey,
cdnNumber,
key: keys,
digest: newDigest,
},
],
timestamp: incomingTimestamp,
@ -209,8 +180,14 @@ describe('attachments', function (this: Mocha.Suite) {
assert.exists(incomingAttachment?.reencryptionInfo);
assert.exists(incomingAttachment?.reencryptionInfo.digest);
assert.strictEqual(incomingAttachment?.key, toBase64(keys));
assert.strictEqual(incomingAttachment?.digest, toBase64(newDigest));
assert.strictEqual(
incomingAttachment?.key,
toBase64(attachment.key ?? new Uint8Array(0))
);
assert.strictEqual(
incomingAttachment?.digest,
toBase64(attachment.digest ?? new Uint8Array(0))
);
assert.notEqual(
incomingAttachment?.digest,
incomingAttachment.reencryptionInfo.digest

View file

@ -12,6 +12,11 @@ export enum InstallScreenStep {
BackupImport = 'BackupImport',
}
export enum InstallScreenBackupStep {
Download = 'Download',
Process = 'Process',
}
export enum InstallScreenError {
TooManyDevices = 'TooManyDevices',
TooOld = 'TooOld',

View file

@ -95,8 +95,6 @@ export async function attemptToReencryptToOriginalDigest(
),
},
needIncrementalMac: false,
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
});
} else {
strictAssert(attachment.size != null, 'Size must exist');
@ -127,8 +125,6 @@ export async function attemptToReencryptToOriginalDigest(
digestToMatch: fromBase64(digest),
},
needIncrementalMac: false,
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
}),
]);
}
@ -150,8 +146,6 @@ export async function generateNewEncryptionInfoForAttachment(
),
},
needIncrementalMac: false,
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
});
} else {
const passthrough = new PassThrough();
@ -177,8 +171,6 @@ export async function generateNewEncryptionInfoForAttachment(
size: attachment.size,
},
needIncrementalMac: false,
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
}),
]);
// eslint-disable-next-line prefer-destructuring