diff --git a/src/libs/snapshotPickedFile/index.native.ts b/src/libs/snapshotPickedFile/index.native.ts new file mode 100644 index 000000000000..3615900ad876 --- /dev/null +++ b/src/libs/snapshotPickedFile/index.native.ts @@ -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 { + if (file.name !== name) { + return Promise.resolve(new File([file], name, {type: file.type})); + } + return Promise.resolve(file); +} + +export default snapshotPickedFile; diff --git a/src/libs/snapshotPickedFile/index.ts b/src/libs/snapshotPickedFile/index.ts new file mode 100644 index 000000000000..713118ecb696 --- /dev/null +++ b/src/libs/snapshotPickedFile/index.ts @@ -0,0 +1,28 @@ +import {isMobile} from '@libs/Browser'; + +// iPadOS Safari in "Request Desktop Website" mode (the default) reports a Macintosh user agent that +// isMobile() can't recognize; real Macs report zero touch points. +function isIPadInDesktopMode(): boolean { + return /Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1; +} + +/** + * 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 { + // Mobile browsers hand over sandboxed temp copies the OS won't touch after picking, and copying + // every file's bytes would multiply peak memory by batch size on memory-constrained mobile + // Safari — keep the lazy File there and only clean the name. + if (isMobile() || isIPadInDesktopMode()) { + if (file.name !== name) { + return new File([file], name, {type: file.type}); + } + return file; + } + return new File([await file.arrayBuffer()], name, {type: file.type, lastModified: file.lastModified}); +} + +export default snapshotPickedFile; diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts index acf42e3308a3..80af6dc300fb 100644 --- a/src/libs/validateAttachmentFile.ts +++ b/src/libs/validateAttachmentFile.ts @@ -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; @@ -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:')) { diff --git a/tests/unit/ValidateAttachmentFileSnapshotTest.ts b/tests/unit/ValidateAttachmentFileSnapshotTest.ts new file mode 100644 index 000000000000..2fc7b9d0d26b --- /dev/null +++ b/tests/unit/ValidateAttachmentFileSnapshotTest.ts @@ -0,0 +1,126 @@ +import {isMobile} from '@libs/Browser'; +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}>('@src/libs/snapshotPickedFile/index.ts')); + +// The web snapshot only copies bytes on desktop browsers; make the browser type controllable per test. +jest.mock('@src/libs/Browser', () => ({ + ...jest.requireActual>('@src/libs/Browser'), + isMobile: jest.fn(() => false), +})); + +// Mock only normalizeFileObject and validateImageForCorruption; keep the rest real +jest.mock('@src/libs/fileDownload/FileUtils', () => { + const actual = jest.requireActual('@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); + jest.mocked(isMobile).mockReturnValue(false); + }); + + 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); + }); + + it('keeps the lazy OS-backed File on mobile browsers so a multi-file selection is not held in memory', async () => { + jest.mocked(isMobile).mockReturnValue(true); + 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'}); + const arrayBufferSpy = jest.fn(); + 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'); + } + // Mobile-picked files are sandboxed temp copies, so the bytes are not copied into memory. + expect(arrayBufferSpy).not.toHaveBeenCalled(); + expect(result.file).toBe(file); + } finally { + createObjectURLSpy.mockRestore(); + } + }); + + it('keeps the lazy File on iPadOS Safari in desktop mode (Macintosh user agent with touch points)', async () => { + const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:new-url'); + const originalUserAgent = navigator.userAgent; + const originalMaxTouchPoints = navigator.maxTouchPoints; + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', {value: 5, configurable: true}); + try { + const file: FileObject = new File([new Blob(['content'], {type: 'text/plain'})], 'image.png', {type: 'image/png'}); + const arrayBufferSpy = jest.fn(); + 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).not.toHaveBeenCalled(); + expect(result.file).toBe(file); + } finally { + createObjectURLSpy.mockRestore(); + Object.defineProperty(navigator, 'userAgent', {value: originalUserAgent, configurable: true}); + Object.defineProperty(navigator, 'maxTouchPoints', {value: originalMaxTouchPoints, configurable: true}); + } + }); +});