Inline long-text messages in the backup proto

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2025-07-21 13:53:24 -05:00 committed by GitHub
parent 7eb30447d8
commit 52c57e3000
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 144 additions and 25 deletions

View file

@ -234,7 +234,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
repository: 'signalapp/Signal-Message-Backup-Tests' repository: 'signalapp/Signal-Message-Backup-Tests'
ref: '316c3d5eb15ffc923b5ce2925094fd49b34e8203' ref: 'df09e4bfa985c68daf845ad96abae3ae8f9b07ca'
path: 'backup-integration-tests' path: 'backup-integration-tests'
- run: xvfb-run --auto-servernum pnpm run test-electron - run: xvfb-run --auto-servernum pnpm run test-electron

View file

@ -144,7 +144,7 @@ import {
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc'; import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
import { SeenStatus } from '../../MessageSeenStatus'; import { SeenStatus } from '../../MessageSeenStatus';
import { migrateAllMessages } from '../../messages/migrateMessageData'; import { migrateAllMessages } from '../../messages/migrateMessageData';
import { trimBody } from '../../util/longAttachment'; import { isBodyTooLong, trimBody } from '../../util/longAttachment';
import { generateBackupsSubscriberData } from '../../util/backupSubscriptionData'; import { generateBackupsSubscriberData } from '../../util/backupSubscriptionData';
import { import {
getEnvironment, getEnvironment,
@ -157,6 +157,7 @@ import { isValidE164 } from '../../util/isValidE164';
import { toDayOfWeekArray } from '../../types/NotificationProfile'; import { toDayOfWeekArray } from '../../types/NotificationProfile';
import { getLinkPreviewSetting } from '../../types/LinkPreview'; import { getLinkPreviewSetting } from '../../types/LinkPreview';
import { getTypingIndicatorSetting } from '../../types/Util'; import { getTypingIndicatorSetting } from '../../types/Util';
import { KIBIBYTE } from '../../types/AttachmentSize';
const log = createLogger('export'); const log = createLogger('export');
@ -170,6 +171,8 @@ const FLUSH_TIMEOUT = 30 * MINUTE;
// Threshold for reporting slow flushes // Threshold for reporting slow flushes
const REPORTING_THRESHOLD = SECOND; const REPORTING_THRESHOLD = SECOND;
const BACKUP_LONG_ATTACHMENT_TEXT_LIMIT = 128 * KIBIBYTE;
type GetRecipientIdOptionsType = type GetRecipientIdOptionsType =
| Readonly<{ | Readonly<{
serviceId: ServiceIdString; serviceId: ServiceIdString;
@ -2799,10 +2802,18 @@ export class BackupExportStream extends Readable {
| 'preview' | 'preview'
| 'reactions' | 'reactions'
| 'received_at' | 'received_at'
| 'timestamp'
>; >;
backupLevel: BackupLevel; backupLevel: BackupLevel;
isLocalBackup: boolean; isLocalBackup: boolean;
}): Promise<Backups.IStandardMessage> { }): Promise<Backups.IStandardMessage> {
if (
message.body &&
isBodyTooLong(message.body, BACKUP_LONG_ATTACHMENT_TEXT_LIMIT)
) {
log.warn(`${message.timestamp}: Message body is too long; will truncate`);
}
return { return {
quote: await this.#toQuote({ quote: await this.#toQuote({
message, message,
@ -2821,7 +2832,10 @@ export class BackupExportStream extends Readable {
}) })
) )
: undefined, : undefined,
longText: message.bodyAttachment longText:
// We only include the bodyAttachment if it's not downloaded; otherwise all text
// is inlined
message.bodyAttachment && !isDownloaded(message.bodyAttachment)
? await this.#processAttachment({ ? await this.#processAttachment({
attachment: message.bodyAttachment, attachment: message.bodyAttachment,
backupLevel, backupLevel,
@ -2832,7 +2846,9 @@ export class BackupExportStream extends Readable {
text: text:
message.body != null message.body != null
? { ? {
body: message.body ? trimBody(message.body) : undefined, body: message.body
? trimBody(message.body, BACKUP_LONG_ATTACHMENT_TEXT_LIMIT)
: undefined,
bodyRanges: message.bodyRanges?.map(range => bodyRanges: message.bodyRanges?.map(range =>
this.#toBodyRange(range) this.#toBodyRange(range)
), ),

View file

@ -41,7 +41,7 @@ import {
generateKeys, generateKeys,
getPlaintextHashForInMemoryAttachment, getPlaintextHashForInMemoryAttachment,
} from '../../AttachmentCrypto'; } from '../../AttachmentCrypto';
import { isValidAttachmentKey } from '../../types/Crypto'; import { KIBIBYTE } from '../../types/AttachmentSize';
const CONTACT_A = generateAci(); const CONTACT_A = generateAci();
@ -149,6 +149,7 @@ describe('backup/attachments', () => {
} }
return base; return base;
} }
describe('long-message attachments', () => { describe('long-message attachments', () => {
it('preserves attachment still on message.attachments', async () => { it('preserves attachment still on message.attachments', async () => {
const longMessageAttachment = composeAttachment(1, { const longMessageAttachment = composeAttachment(1, {
@ -188,7 +189,7 @@ describe('backup/attachments', () => {
], ],
[ [
composeMessage(1, { composeMessage(1, {
body: body.slice(0, 2048), body,
bodyAttachment: { bodyAttachment: {
contentType: LONG_MESSAGE, contentType: LONG_MESSAGE,
size: bodyBytes.byteLength, size: bodyBytes.byteLength,
@ -206,22 +207,23 @@ describe('backup/attachments', () => {
assert.deepStrictEqual( assert.deepStrictEqual(
expected.bodyAttachment, expected.bodyAttachment,
// all encryption info will be generated anew omit(msgInDB.bodyAttachment, ['localKey', 'path', 'version'])
omit(msgInDB.bodyAttachment, ['digest', 'key', 'downloadPath'])
); );
assert.isUndefined(msgInDB.bodyAttachment?.digest); assert.isUndefined(msgInDB.bodyAttachment?.digest);
assert.isTrue(isValidAttachmentKey(msgInDB.bodyAttachment?.key));
}, },
} }
); );
}); });
it('handles existing bodyAttachments', async () => { it('handles existing bodyAttachments', async () => {
const body = 'a'.repeat(3000);
const bodyBytes = Bytes.fromString(body);
const attachment = omit( const attachment = omit(
composeAttachment(1, { composeAttachment(1, {
contentType: LONG_MESSAGE, contentType: LONG_MESSAGE,
size: 3000, size: bodyBytes.byteLength,
downloadPath: 'downloadPath', downloadPath: 'downloadPath',
}), }),
'thumbnail' 'thumbnail'
@ -232,13 +234,17 @@ describe('backup/attachments', () => {
[ [
composeMessage(1, { composeMessage(1, {
bodyAttachment: attachment, bodyAttachment: attachment,
body: 'a'.repeat(3000), body,
}), }),
], ],
[ [
composeMessage(1, { composeMessage(1, {
body: 'a'.repeat(2048), body,
bodyAttachment: expectedRoundtrippedFields(attachment), bodyAttachment: {
contentType: LONG_MESSAGE,
size: 3000,
plaintextHash: getPlaintextHashForInMemoryAttachment(bodyBytes),
},
}), }),
], ],
{ {
@ -250,11 +256,105 @@ describe('backup/attachments', () => {
); );
assert.deepStrictEqual( assert.deepStrictEqual(
omit(expected.bodyAttachment, ['clientUuid', 'downloadPath']), expected.bodyAttachment,
omit(msgInDB.bodyAttachment, ['clientUuid', 'downloadPath']) omit(msgInDB.bodyAttachment, ['localKey', 'path', 'version'])
);
},
}
);
});
it('truncates at 128 KiB', async () => {
const body = 'a'.repeat(129 * KIBIBYTE);
const truncatedBody = body.slice(0, 128 * KIBIBYTE);
const bodyBytes = Bytes.fromString(body);
const attachment = omit(
composeAttachment(1, {
contentType: LONG_MESSAGE,
size: bodyBytes.byteLength,
downloadPath: 'downloadPath',
}),
'thumbnail'
);
strictAssert(attachment.digest, 'must exist');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
bodyAttachment: attachment,
body,
}),
],
[
composeMessage(1, {
body: truncatedBody,
bodyAttachment: {
contentType: LONG_MESSAGE,
size: 128 * KIBIBYTE,
plaintextHash: getPlaintextHashForInMemoryAttachment(
Bytes.fromString(truncatedBody)
),
},
}),
],
{
backupLevel: BackupLevel.Paid,
comparator: (expected, msgInDB) => {
assert.deepStrictEqual(
omit(expected, 'bodyAttachment'),
omit(msgInDB, 'bodyAttachment')
); );
assert.isNotEmpty(msgInDB.bodyAttachment?.downloadPath); assert.deepStrictEqual(
expected.bodyAttachment,
omit(msgInDB.bodyAttachment, ['localKey', 'path', 'version'])
);
},
}
);
});
it('includes bodyAttachment if it has not downloaded', async () => {
const truncatedBody = 'a'.repeat(2 * KIBIBYTE);
const attachment = omit(
composeAttachment(1, {
contentType: LONG_MESSAGE,
size: 64 * KIBIBYTE,
path: undefined,
plaintextHash: undefined,
localKey: undefined,
downloadPath: undefined,
clientUuid: undefined, // clientUuids are not roundtripped for bodyAttachments
}),
'thumbnail'
);
strictAssert(attachment.digest, 'must exist');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
body: truncatedBody,
bodyAttachment: attachment,
}),
],
[
composeMessage(1, {
body: truncatedBody,
bodyAttachment: attachment,
}),
],
{
backupLevel: BackupLevel.Paid,
comparator: (expected, msgInDB) => {
assert.deepStrictEqual(
omit(msgInDB, 'bodyAttachment'),
omit(expected, 'bodyAttachment')
);
assert.deepStrictEqual(
omit(msgInDB.bodyAttachment, ['downloadPath']),
expected.bodyAttachment
);
}, },
} }
); );

View file

@ -5,8 +5,11 @@ import { unicodeSlice } from './unicodeSlice';
const LONG_ATTACHMENT_LIMIT = 2048; const LONG_ATTACHMENT_LIMIT = 2048;
export function isBodyTooLong(body: string): boolean { export function isBodyTooLong(
return Buffer.byteLength(body) > LONG_ATTACHMENT_LIMIT; body: string,
length = LONG_ATTACHMENT_LIMIT
): boolean {
return Buffer.byteLength(body) > length;
} }
export function trimBody(body: string, length = LONG_ATTACHMENT_LIMIT): string { export function trimBody(body: string, length = LONG_ATTACHMENT_LIMIT): string {