Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/libs/snapshotPickedFile/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Native keeps the picked File as-is: there is no IndexedDB blob path to poison, and the
* File polyfill has no arrayBuffer. Only rename when the cleaned name differs.
*/
function snapshotPickedFile(file: File, name: string): Promise<File> {
if (file.name !== name) {
return Promise.resolve(new File([file], name, {type: file.type}));
}
return Promise.resolve(file);
}

export default snapshotPickedFile;
11 changes: 11 additions & 0 deletions src/libs/snapshotPickedFile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Copies a picked file's bytes into a memory-backed File. A picked File only references its OS
* file, so if that file is modified or deleted before the queued request is persisted, the
* IndexedDB write fails with "Failed to write blobs" and poisons the persisted request queue.
* Rejects when the backing file is already unreadable.
*/
async function snapshotPickedFile(file: File, name: string): Promise<File> {
return new File([await file.arrayBuffer()], name, {type: file.type, lastModified: file.lastModified});
}

export default snapshotPickedFile;
15 changes: 10 additions & 5 deletions src/libs/validateAttachmentFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {FileObject} from '@src/types/utils/Attachment';
import type {ValueOf} from 'type-fest';

import {cleanFileName, hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils';
import snapshotPickedFile from './snapshotPickedFile';

type ValidateAttachmentValidResult = {
isValid: true;
Expand Down Expand Up @@ -67,12 +68,16 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem,
*/
let updatedFile = normalizedFile;
const cleanName = cleanFileName(updatedFile.name);
if (updatedFile.name !== cleanName) {
updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type});
// On web this snapshots the bytes into a memory-backed File so a later change to the OS file
// can't invalidate the queued request (see snapshotPickedFile); on native it only cleans the name.
try {
updatedFile = await snapshotPickedFile(updatedFile, cleanName);
} catch {
// The backing file was already modified or deleted since it was picked.
return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID};
}
// Read the superseded URI from normalizedFile: when the name needed cleaning, updatedFile was
// reassigned to a fresh File that doesn't carry the custom .uri property, so reading it there
// would skip the revoke exactly for cleaned filenames (e.g. default macOS screenshot names).
// Read the superseded URI from normalizedFile: updatedFile is always a fresh File that
// doesn't carry the custom .uri property.
const previousUri = normalizedFile.uri;
const inputSource = URL.createObjectURL(updatedFile);
if (previousUri && previousUri !== inputSource && previousUri.startsWith('blob:')) {
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/ValidateAttachmentFileSnapshotTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import validateAttachmentFile from '@libs/validateAttachmentFile';

import type {FileObject} from '@src/types/utils/Attachment';

import CONST from '../../src/CONST';
import * as FileUtils from '../../src/libs/fileDownload/FileUtils';

// Jest resolves the .native variant of platform-split modules; force the web implementation
// since the OS-file snapshot behavior under test is web-only.
jest.mock('@src/libs/snapshotPickedFile', () => jest.requireActual<{default: (file: File, name: string) => Promise<File>}>('@src/libs/snapshotPickedFile/index.ts'));

// Mock only normalizeFileObject and validateImageForCorruption; keep the rest real
jest.mock('@src/libs/fileDownload/FileUtils', () => {
const actual = jest.requireActual<typeof FileUtils>('@src/libs/fileDownload/FileUtils');
return {
...actual,
normalizeFileObject: jest.fn(),
validateImageForCorruption: jest.fn(),
};
});

const mockFileUtils = jest.mocked(FileUtils);

describe('validateAttachmentFile OS-backed file snapshot (web)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFileUtils.normalizeFileObject.mockImplementation(async (file) => file);
mockFileUtils.validateImageForCorruption.mockResolvedValue(undefined);
});

it('snapshots the picked file bytes into a new memory-backed File', async () => {
const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:new-url');
try {
const file: FileObject = new File([new Blob(['content'], {type: 'text/plain'})], 'image.png', {type: 'image/png'});
// The RN File polyfill in Jest has no arrayBuffer; emulate the web File API.
const arrayBufferSpy = jest.fn().mockResolvedValue(new ArrayBuffer(7));
Object.defineProperty(file, 'arrayBuffer', {value: arrayBufferSpy, configurable: true});

const result = await validateAttachmentFile(file);

expect(result.isValid).toBe(true);
if (!result.isValid) {
throw new Error('validateAttachmentFile should return a valid result');
}
expect(arrayBufferSpy).toHaveBeenCalled();
// The returned File must be a fresh memory-backed copy, not the OS-backed original.
expect(result.file).not.toBe(file);
} finally {
createObjectURLSpy.mockRestore();
}
});

it('returns FILE_INVALID when the picked file can no longer be read (deleted or modified on disk)', async () => {
const file: FileObject = new File([new Blob(['content'], {type: 'text/plain'})], 'image.png', {type: 'image/png'});
// Chromium rejects the read when the backing OS file changed since it was picked.
const arrayBufferSpy = jest.fn().mockRejectedValue(new DOMException('The requested file could not be read', 'NotReadableError'));
Object.defineProperty(file, 'arrayBuffer', {value: arrayBufferSpy, configurable: true});

const result = await validateAttachmentFile(file);

expect(result.isValid).toBe(false);
if (result.isValid) {
throw new Error('validateAttachmentFile should return an invalid result');
}
expect(result.error).toBe(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID);
});
});
Loading