// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; import fsExtra from 'fs-extra'; import { v4 as generateUuid } from 'uuid'; import { readdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { DataWriter } from '../sql/Client.preload.js'; import { missingCaseError } from '../util/missingCaseError.std.js'; import { getAbsoluteAttachmentPath, getAbsoluteDownloadsPath, getAbsoluteDraftPath, getAbsoluteMegaphoneImageFilePath, } from '../util/migrations.preload.js'; import { getDownloadsPath, getDraftPath, getAttachmentsPath, } from '../windows/main/attachments.preload.js'; import { generateAci } from '../types/ServiceId.std.js'; import { IMAGE_JPEG, LONG_MESSAGE } from '../types/MIME.std.js'; import type { MessageAttributesType } from '../model-types.d.ts'; import type { RemoteMegaphoneId } from '../types/Megaphone.std.js'; const { emptyDir, ensureFile } = fsExtra; type TestAttachmentTypes = 'attachment' | 'download' | 'draft' | 'megaphone'; function getAbsolutePath(path: string, type: TestAttachmentTypes) { switch (type) { case 'attachment': return getAbsoluteAttachmentPath(path); case 'download': return getAbsoluteDownloadsPath(path); case 'draft': return getAbsoluteDraftPath(path); case 'megaphone': return getAbsoluteMegaphoneImageFilePath(path); default: throw missingCaseError(type); } } async function writeFile(path: string, type: TestAttachmentTypes) { await ensureFile(getAbsolutePath(path, type)); } async function writeFiles(num: number, type: TestAttachmentTypes) { for (let i = 0; i < num; i += 1) { // eslint-disable-next-line no-await-in-loop await writeFile(`${type}${i}`, type); } } function listFiles(type: TestAttachmentTypes): Array { return readdirSync(dirname(getAbsolutePath('fakename', type))); } let attachmentIndex = 0; let downloadIndex = 0; describe('cleanupOrphanedAttachments', () => { // TODO (DESKTOP-8613): stickers & badges beforeEach(async () => { await DataWriter.removeAll(); attachmentIndex = 0; downloadIndex = 0; await emptyDir( getAttachmentsPath(window.SignalContext.config.userDataPath) ); await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath)); await emptyDir(getDraftPath(window.SignalContext.config.userDataPath)); }); afterEach(async () => { await emptyDir( getAttachmentsPath(window.SignalContext.config.userDataPath) ); await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath)); await emptyDir(getDraftPath(window.SignalContext.config.userDataPath)); }); function getAttachmentFilePath() { attachmentIndex += 1; return `attachment${attachmentIndex}`; } function getDownloadFilePath() { downloadIndex += 1; return `download${downloadIndex}`; } function composeAttachment() { return { contentType: IMAGE_JPEG, size: 128, path: getAttachmentFilePath(), downloadPath: getDownloadFilePath(), thumbnail: { contentType: IMAGE_JPEG, size: 128, path: getAttachmentFilePath(), }, screenshot: { contentType: IMAGE_JPEG, size: 128, path: getAttachmentFilePath(), }, thumbnailFromBackup: { contentType: IMAGE_JPEG, size: 128, path: getAttachmentFilePath(), }, }; } function composeBodyAttachment() { return { contentType: LONG_MESSAGE, size: 128, path: getAttachmentFilePath(), downloadPath: getDownloadFilePath(), }; } it('deletes paths if not referenced', async () => { await writeFiles(2, 'attachment'); await writeFiles(2, 'draft'); await writeFiles(2, 'download'); assert.sameDeepMembers(listFiles('attachment'), [ 'attachment0', 'attachment1', ]); assert.sameDeepMembers(listFiles('draft'), ['draft0', 'draft1']); assert.sameDeepMembers(listFiles('download'), ['download0', 'download1']); await DataWriter.cleanupOrphanedAttachments({ _block: true }); assert.sameDeepMembers(listFiles('attachment'), []); assert.sameDeepMembers(listFiles('draft'), []); assert.sameDeepMembers(listFiles('download'), []); }); it('does not delete conversation avatar and profileAvatar paths', async () => { await writeFiles(6, 'attachment'); await DataWriter.saveConversation({ id: generateUuid(), type: 'private', version: 2, expireTimerVersion: 2, avatar: { path: 'attachment0', }, profileAvatar: { path: 'attachment1', }, }); await DataWriter.cleanupOrphanedAttachments({ _block: true }); assert.sameDeepMembers(listFiles('attachment'), [ 'attachment0', 'attachment1', ]); }); describe('message attachments', () => { // Update these if more paths are added to composeMessageWithAllAttachments const NUM_ATTACHMENT_FILES_IN_MESSAGE = 26; const NUM_DOWNLOAD_FILES_IN_MESSAGE = 8; function composeMessageWithAllAttachments(): MessageAttributesType { return { id: generateUuid(), type: 'outgoing', sent_at: Date.now(), timestamp: Date.now(), received_at: Date.now(), conversationId: generateUuid(), bodyAttachment: composeBodyAttachment(), attachments: [composeAttachment(), composeAttachment()], contact: [ { avatar: { isProfile: false, avatar: composeAttachment(), }, }, ], preview: [ { url: 'url', image: composeAttachment(), }, ], editHistory: [ { timestamp: Date.now(), received_at: Date.now(), bodyAttachment: composeBodyAttachment(), }, ], quote: { id: Date.now(), isViewOnce: false, referencedMessageNotFound: false, attachments: [ { contentType: IMAGE_JPEG, thumbnail: composeAttachment(), }, ], }, sticker: { packId: 'packId', stickerId: 42, packKey: 'packKey', data: composeAttachment(), }, }; } it('does not delete message attachments (including thumbnails, previews, avatars, etc.)', async () => { await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment'); await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download'); await DataWriter.saveMessage(composeMessageWithAllAttachments(), { ourAci: generateAci(), forceSave: true, postSaveUpdates: () => Promise.resolve(), }); await DataWriter.cleanupOrphanedAttachments({ _block: true }); assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE); assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE); const attachmentFiles = listFiles('attachment'); const downloadFiles = listFiles('download'); assert.strictEqual( attachmentFiles.length, NUM_ATTACHMENT_FILES_IN_MESSAGE ); assert.sameDeepMembers( attachmentFiles, new Array(attachmentIndex) .fill(null) .map((_, idx) => `attachment${idx + 1}`) ); assert.strictEqual(downloadFiles.length, NUM_DOWNLOAD_FILES_IN_MESSAGE); assert.sameDeepMembers( downloadFiles, new Array(downloadIndex) .fill(null) .map((_, idx) => `download${idx + 1}`) ); }); it('does not delete any protected attachment paths', async () => { await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment'); await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download'); await DataWriter.saveMessage(composeMessageWithAllAttachments(), { ourAci: generateAci(), forceSave: true, postSaveUpdates: () => Promise.resolve(), }); await DataWriter._protectAttachmentPathFromDeletion({ path: `attachment${attachmentIndex + 1}`, messageId: 'messageId', }); await DataWriter.cleanupOrphanedAttachments({ _block: true }); assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE); const attachmentFiles = listFiles('attachment'); assert.strictEqual( attachmentFiles.length, NUM_ATTACHMENT_FILES_IN_MESSAGE + 1 ); assert.sameDeepMembers( attachmentFiles, new Array(attachmentIndex + 1) .fill(null) .map((_, idx) => `attachment${idx + 1}`) ); }); it('works with non-normalized message attachments', async () => { await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment'); await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download'); await DataWriter.saveMessage(composeMessageWithAllAttachments(), { ourAci: generateAci(), forceSave: true, // Save one with attachments not normalized _testOnlyAvoidNormalizingAttachments: true, postSaveUpdates: () => Promise.resolve(), }); await DataWriter.cleanupOrphanedAttachments({ _block: true }); assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE); assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE); const attachmentFiles = listFiles('attachment'); const downloadFiles = listFiles('download'); assert.strictEqual( attachmentFiles.length, NUM_ATTACHMENT_FILES_IN_MESSAGE ); assert.sameDeepMembers( attachmentFiles, new Array(attachmentIndex) .fill(null) .map((_, idx) => `attachment${idx + 1}`) ); assert.strictEqual(downloadFiles.length, NUM_DOWNLOAD_FILES_IN_MESSAGE); assert.sameDeepMembers( downloadFiles, new Array(downloadIndex) .fill(null) .map((_, idx) => `download${idx + 1}`) ); }); it('will NOT delete copied quote attachments if there is at least one strong reference', async () => { await writeFiles(10, 'attachment'); const quotedMessage = { id: generateUuid(), type: 'outgoing', sent_at: Date.now(), timestamp: Date.now(), received_at: Date.now(), conversationId: generateUuid(), attachments: [ { contentType: IMAGE_JPEG, size: 128, path: 'attachment1', thumbnail: { contentType: IMAGE_JPEG, size: 42, // strong reference path: 'attachment2', }, }, ], } as const; const quotingMessage = { id: generateUuid(), type: 'outgoing', sent_at: Date.now(), timestamp: Date.now(), received_at: Date.now(), conversationId: generateUuid(), quote: { id: quotedMessage.sent_at, isViewOnce: false, referencedMessageNotFound: false, attachments: [ { contentType: IMAGE_JPEG, thumbnail: { contentType: IMAGE_JPEG, size: 42, // weak (copied) reference path: 'attachment2', copied: true, }, }, ], }, } as const; // Make sure we constructed the test correctly: both attachments reference the same // path on disk assert.strictEqual( quotedMessage.attachments[0].thumbnail.path, quotingMessage.quote.attachments[0].thumbnail.path ); await DataWriter.saveMessages([quotedMessage, quotingMessage], { ourAci: generateAci(), forceSave: true, postSaveUpdates: () => Promise.resolve(), }); await DataWriter.cleanupOrphanedAttachments({ _block: true }); const attachmentFilesLeftOnDisk = listFiles('attachment'); assert.strictEqual(attachmentFilesLeftOnDisk.length, 2); assert.sameDeepMembers(attachmentFilesLeftOnDisk, [ 'attachment1', 'attachment2', ]); }); it('will delete quote attachments if there are only weak references', async () => { await writeFiles(10, 'attachment'); const quotingMessage = { id: generateUuid(), type: 'outgoing', sent_at: Date.now(), timestamp: Date.now(), received_at: Date.now(), conversationId: generateUuid(), quote: { id: Date.now(), isViewOnce: false, referencedMessageNotFound: false, attachments: [ { contentType: IMAGE_JPEG, thumbnail: { contentType: IMAGE_JPEG, size: 42, path: 'attachment1', copied: true, }, }, ], }, } as const; await DataWriter.saveMessage(quotingMessage, { ourAci: generateAci(), forceSave: true, postSaveUpdates: () => Promise.resolve(), }); await DataWriter.cleanupOrphanedAttachments({ _block: true }); const attachmentFilesLeftOnDisk = listFiles('attachment'); assert.strictEqual(attachmentFilesLeftOnDisk.length, 0); }); }); it('does not delete megaphone image paths', async () => { // Write 2 files so 1 is orphaned await writeFiles(2, 'megaphone'); await DataWriter.createMegaphone({ id: generateUuid() as RemoteMegaphoneId, desktopMinVersion: '1.0.0', priority: 1, dontShowBeforeEpochMs: 0, dontShowAfterEpochMs: Date.now() + 9001, showForNumberOfDays: 7, primaryCtaId: null, secondaryCtaId: null, primaryCtaData: null, secondaryCtaData: null, conditionalId: null, title: 'a', body: 'b', primaryCtaText: null, secondaryCtaText: null, imagePath: 'megaphone0', localeFetched: 'en', shownAt: null, snoozedAt: null, snoozeCount: 0, isFinished: false, }); await DataWriter.cleanupOrphanedAttachments({ _block: true }); // Only the file associated with the megaphone is retained assert.sameDeepMembers(listFiles('megaphone'), ['megaphone0']); }); });