From ed4a44159e679768ceb62cb80c74c590484f55dd Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Thu, 23 Jul 2026 17:04:10 +0200 Subject: [PATCH 1/3] Snapshot picked files into memory-backed Files to prevent queue poisoning A File picked from disk only references its OS file. If that file is modified or deleted before the queued request is persisted (e.g. while the preview modal is open), the IndexedDB write fails with "DataError: Failed to write blobs" (InvalidBlob/IOError) and the dead File poisons the persisted request queue, failing every later rewrite. Copy the bytes into a memory-backed File at validation time, so the queued request can never reference a stale OS file. If the file is already unreadable at validation, surface FILE_INVALID to the user instead of failing silently later. Co-Authored-By: Claude Fable 5 --- src/libs/validateAttachmentFile.ts | 17 +++++++--- tests/unit/ValidateAttachmentFileTest.ts | 40 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts index acf42e3308a3..45c29c8cb487 100644 --- a/src/libs/validateAttachmentFile.ts +++ b/src/libs/validateAttachmentFile.ts @@ -67,12 +67,21 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, */ let updatedFile = normalizedFile; const cleanName = cleanFileName(updatedFile.name); - if (updatedFile.name !== cleanName) { + // Snapshot the 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 request queue. RN's File has no arrayBuffer. + if (typeof updatedFile.arrayBuffer === 'function') { + try { + updatedFile = new File([await updatedFile.arrayBuffer()], cleanName, {type: updatedFile.type, lastModified: updatedFile.lastModified}); + } catch { + // The backing file was already modified or deleted since it was picked. + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; + } + } else if (updatedFile.name !== cleanName) { updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); } - // 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/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index 4f86e96fe1f7..8fcb29398a87 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -403,4 +403,44 @@ describe('validateAttachmentFile', () => { } }); }); + + describe('OS-backed file snapshot', () => { + 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)); + (file as unknown as {arrayBuffer: () => Promise}).arrayBuffer = arrayBufferSpy; + + 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. + (file as unknown as {arrayBuffer: () => Promise}).arrayBuffer = jest + .fn() + .mockRejectedValue(new DOMException('The requested file could not be read', 'NotReadableError')); + + 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); + }); + }); }); From 2ac142d5d07e3e166432df2c264b327fb365c1fa Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Fri, 24 Jul 2026 10:15:58 +0200 Subject: [PATCH 2/3] Avoid unsafe type assertions in arrayBuffer test mocks eslint-seatbelt caps no-unsafe-type-assertion at the grandfathered count for this file; use Object.defineProperty instead of casting. Co-Authored-By: Claude Fable 5 --- tests/unit/ValidateAttachmentFileTest.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index 8fcb29398a87..8a106c95bc83 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -411,7 +411,7 @@ describe('validateAttachmentFile', () => { 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)); - (file as unknown as {arrayBuffer: () => Promise}).arrayBuffer = arrayBufferSpy; + Object.defineProperty(file, 'arrayBuffer', {value: arrayBufferSpy, configurable: true}); const result = await validateAttachmentFile(file); @@ -430,9 +430,8 @@ describe('validateAttachmentFile', () => { 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. - (file as unknown as {arrayBuffer: () => Promise}).arrayBuffer = jest - .fn() - .mockRejectedValue(new DOMException('The requested file could not be read', 'NotReadableError')); + 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); From 04ed14ced5d4979cf50c2499df6e20b328ef2fde Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Fri, 24 Jul 2026 13:29:00 +0200 Subject: [PATCH 3/3] Move file snapshot into a platform-split module Replace the arrayBuffer feature-detect with snapshotPickedFile/index.ts (web: copy bytes into a memory-backed File) and index.native.ts (keep the original rename-only behavior; native has no IndexedDB blob path). Snapshot tests move to their own file that pins the web implementation, since Jest resolves the .native variant by default. Co-Authored-By: Claude Fable 5 --- src/libs/snapshotPickedFile/index.native.ts | 12 ++++ src/libs/snapshotPickedFile/index.ts | 11 +++ src/libs/validateAttachmentFile.ts | 20 +++--- .../ValidateAttachmentFileSnapshotTest.ts | 67 +++++++++++++++++++ tests/unit/ValidateAttachmentFileTest.ts | 39 ----------- 5 files changed, 98 insertions(+), 51 deletions(-) create mode 100644 src/libs/snapshotPickedFile/index.native.ts create mode 100644 src/libs/snapshotPickedFile/index.ts create mode 100644 tests/unit/ValidateAttachmentFileSnapshotTest.ts 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..72b6545a9d2d --- /dev/null +++ b/src/libs/snapshotPickedFile/index.ts @@ -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 { + 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 45c29c8cb487..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,18 +68,13 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, */ let updatedFile = normalizedFile; const cleanName = cleanFileName(updatedFile.name); - // Snapshot the 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 request queue. RN's File has no arrayBuffer. - if (typeof updatedFile.arrayBuffer === 'function') { - try { - updatedFile = new File([await updatedFile.arrayBuffer()], cleanName, {type: updatedFile.type, lastModified: updatedFile.lastModified}); - } catch { - // The backing file was already modified or deleted since it was picked. - return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; - } - } else 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: updatedFile is always a fresh File that // doesn't carry the custom .uri property. diff --git a/tests/unit/ValidateAttachmentFileSnapshotTest.ts b/tests/unit/ValidateAttachmentFileSnapshotTest.ts new file mode 100644 index 000000000000..554136f7b94b --- /dev/null +++ b/tests/unit/ValidateAttachmentFileSnapshotTest.ts @@ -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}>('@src/libs/snapshotPickedFile/index.ts')); + +// 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); + }); + + 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); + }); +}); diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index 8a106c95bc83..4f86e96fe1f7 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -403,43 +403,4 @@ describe('validateAttachmentFile', () => { } }); }); - - describe('OS-backed file snapshot', () => { - 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); - }); - }); });