From 811a448dfd4662e57ac3266b16d19eccada97259 Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Mon, 21 Sep 2026 09:08:04 +0000 Subject: [PATCH 1/9] Give downloaded attachments a file extension Recover a missing extension from the MIME type at upload, and pass the stored file name into the video player's download action instead of falling back to the extensionless storage key. Co-authored-by: Cong Pham --- package-lock.json | 1 + package.json | 1 + .../AttachmentViewVideo/index.tsx | 6 +- .../Attachments/AttachmentView/index.tsx | 1 + .../Composer/implementation/index.native.tsx | 5 +- .../HTMLRenderers/VideoRenderer.tsx | 4 +- .../VideoPlayer/BaseVideoPlayer.tsx | 2 + .../VideoPlayer/VideoPlayerControls/index.tsx | 10 ++- src/components/VideoPlayer/types.ts | 6 ++ .../VideoPopoverMenuContext.tsx | 12 ++- src/components/VideoPlayerContexts/types.ts | 3 +- src/libs/fileDownload/FileUtils.ts | 31 +++++++ src/libs/validateAttachmentFile.ts | 16 +++- tests/unit/FileUtilsTest.ts | 55 ++++++++++++ tests/unit/ValidateAttachmentFileTest.ts | 85 +++++++++++++++++++ 15 files changed, 226 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50d846335eff..d4f020529dc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,7 @@ "mapbox-gl": "^3.24.0", "metro": "0.84.4", "metro-transform-plugins": "0.84.4", + "mime-db": "1.52.0", "onfido-sdk-ui": "14.53.1", "pako": "^2.1.0", "process": "^0.11.10", diff --git a/package.json b/package.json index a61b1c900334..710e0e55137a 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "mapbox-gl": "^3.24.0", "metro": "0.84.4", "metro-transform-plugins": "0.84.4", + "mime-db": "1.52.0", "onfido-sdk-ui": "14.53.1", "pako": "^2.1.0", "process": "^0.11.10", diff --git a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx index 5eb2d610efb8..d920dccc1c4e 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx @@ -11,6 +11,9 @@ type AttachmentViewVideoProps = Pick void; }; -function AttachmentViewVideo({source, isHovered = false, shouldUseSharedVideoElement = false, duration = 0, reportID, onTap}: AttachmentViewVideoProps) { +function AttachmentViewVideo({source, fileName, isHovered = false, shouldUseSharedVideoElement = false, duration = 0, reportID, onTap}: AttachmentViewVideoProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); return ( source.startsWith(prefix))} isHovered={isHovered} duration={duration} diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 7962200faf42..064b09fe870f 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -10,7 +10,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {containsOnlyEmojis} from '@libs/EmojiUtils'; -import {splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; +import {getExtensionFromMimeType, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; import getLandscapeTextInputRefProxy from '@libs/getLandscapeTextInputRefProxy'; import Parser from '@libs/Parser'; @@ -22,7 +22,6 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {NativeSyntheticEvent, TextInputChangeEvent, TextInputPasteEventData} from 'react-native'; -import mimeDb from 'mime-db'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {StyleSheet} from 'react-native'; @@ -114,7 +113,7 @@ function Composer({ const fileURI = clipboardContent?.data; const baseFileName = fileURI?.split('/').pop() ?? 'file'; const {fileName: stem, fileExtension: originalFileExtension} = splitExtensionFromFileName(baseFileName); - const fileExtension = originalFileExtension || (mimeDb[mimeType]?.extensions?.[0] ?? 'bin'); + const fileExtension = originalFileExtension || (getExtensionFromMimeType(mimeType) ?? 'bin'); const fileName = `${stem}.${fileExtension}`; let file: FileObject = {uri: fileURI, name: fileName, type: mimeType, size: 0}; getFileSize(file.uri ?? '') diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index a1386db509ca..7c8713c71e5e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -23,7 +23,9 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { const htmlAttribs = tnode.attributes; const attrHref = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || htmlAttribs.src || htmlAttribs.href || ''; const sourceURL = tryResolveUrlFromApiRoot(attrHref); - const fileName = getFileName(`${sourceURL}`); + // Prefer the stored file name, like ImageRenderer does: deriving it from the URL yields the raw storage + // key, which has no extension and so downloads as a file the OS treats as a generic document. + const fileName = htmlAttribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || getFileName(`${sourceURL}`); const thumbnailUrl = tryResolveUrlFromApiRoot(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_URL_ATTRIBUTE]); const width = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE]); const height = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE]); diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 0ab5651b86dd..42a6724c6005 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -41,6 +41,7 @@ type BaseVideoPlayerProps = VideoPlayerProps & {reportID: string}; function BaseVideoPlayer(props: BaseVideoPlayerProps) { const { url, + fileName, onSourceLoaded, isLooping = false, style, @@ -651,6 +652,7 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { duration={duration || 0} position={currentTime || 0} url={url} + fileName={fileName} videoPlayerRef={videoPlayerRef} videoViewRef={videoViewRef} isPlaying={isPlaying} diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/index.tsx index edbad0085155..bc71e0f9995e 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/index.tsx @@ -32,6 +32,10 @@ type VideoPlayerControlsProps = { position: number; url: string; + + /** Original file name of the video, used to name the file saved by the popover menu's Download action. */ + fileName?: string; + videoPlayerRef: RefObject; videoViewRef: RefObject; isPlaying: boolean; @@ -56,7 +60,7 @@ type VideoPlayerControlsProps = { }; /** Three-dots overflow trigger; records the active player + source before opening. */ -function MoreMenuTrigger({videoPlayerRef, url, small}: {videoPlayerRef: RefObject; url: string; small: boolean}) { +function MoreMenuTrigger({videoPlayerRef, url, fileName, small}: {videoPlayerRef: RefObject; url: string; fileName: string | undefined; small: boolean}) { const {updateVideoPopoverMenuPlayerRef, updateSource} = useVideoPopoverMenuActions(); const icons = useMemoizedLazyExpensifyIcons(['ThreeDots']); const {translate} = useLocalize(); @@ -67,7 +71,7 @@ function MoreMenuTrigger({videoPlayerRef, url, small}: {videoPlayerRef: RefObjec return; } updateVideoPopoverMenuPlayerRef(videoPlayerRef.current); - updateSource(url); + updateSource(url, fileName); }; return ( @@ -87,6 +91,7 @@ function VideoPlayerControls({ duration, position, url, + fileName, videoPlayerRef, videoViewRef, isPlaying, @@ -170,6 +175,7 @@ function VideoPlayerControls({ diff --git a/src/components/VideoPlayer/types.ts b/src/components/VideoPlayer/types.ts index 7f25ce333985..9616527d2660 100644 --- a/src/components/VideoPlayer/types.ts +++ b/src/components/VideoPlayer/types.ts @@ -10,6 +10,12 @@ type VideoPlayerProps = { */ url: string; + /** + * Original file name of the video, used to name the file saved by the player's Download action. + * Without it the download falls back to the raw storage key, which has no extension. + */ + fileName?: string; + /** * Callback fired when the sourceLoad event is triggered. * @param event Data containing information about the video source that finished loading. diff --git a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx index 948074e4b0c9..35751bc251f6 100644 --- a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx +++ b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx @@ -23,6 +23,7 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) { const {translate} = useLocalize(); const {currentVideoPlayerRef, originalParent} = usePlaybackStateContext(); const [source, setSource] = useState(''); + const [sourceFileName, setSourceFileName] = useState(undefined); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS[3]); const isLocalFile = !!source && CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix)); const videoPopoverMenuPlayerRef = useRef(null); @@ -55,18 +56,25 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) { videoPopoverMenuPlayerRef.current = videoPlayer; }; + const updateSource = (newSource: string, newFileName?: string) => { + setSource(newSource); + setSourceFileName(newFileName); + }; + const downloadAttachment = () => { if (typeof source === 'number' || !source) { return; } - fileDownload(translate, addEncryptedAuthTokenToURL(source, encryptedAuthToken)); + // Without the original file name the download falls back to the last segment of the URL, which is the + // raw storage key and carries no extension, so the saved file opens as a generic document. + fileDownload(translate, addEncryptedAuthTokenToURL(source, encryptedAuthToken), sourceFileName); }; const stateValue = {currentPlaybackSpeed, isLocalFile}; const actionsValue = { updateVideoPopoverMenuPlayerRef, updatePlaybackSpeed, - updateSource: setSource, + updateSource, downloadAttachment, }; diff --git a/src/components/VideoPlayerContexts/types.ts b/src/components/VideoPlayerContexts/types.ts index 55c9fd5cbf27..c9a37f73f238 100644 --- a/src/components/VideoPlayerContexts/types.ts +++ b/src/components/VideoPlayerContexts/types.ts @@ -54,8 +54,9 @@ type VideoPopoverMenuActionsContextType = { /** * Updates the video source URL inside VideoPopoverMenuContext. * @param source The new source URL. + * @param fileName The original file name of the source, used to name the downloaded file. */ - updateSource: (source: string) => void; + updateSource: (source: string, fileName?: string) => void; /** Triggers the download flow for the current source. */ downloadAttachment: () => void; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 524dc7bf33ae..822cea272a5b 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -14,6 +14,7 @@ import type {ReactNativeBlobUtilReadStream} from 'react-native-blob-util'; import type {TupleToUnion, ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; +import mimeDb from 'mime-db'; import {Alert, Linking, Platform} from 'react-native'; import ReactNativeBlobUtil from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; @@ -184,6 +185,34 @@ function getFileNameWithFallback(fileName: string | null | undefined, uri: strin return fileNameFromURI; } +/** + * Returns the most common file extension registered for a MIME type, e.g. `video/mp4` -> `mp4`. + * Returns undefined for an empty or unrecognized MIME type so callers can pick their own fallback. + */ +function getExtensionFromMimeType(mimeType: string | undefined): string | undefined { + if (!mimeType) { + return undefined; + } + return mimeDb[mimeType]?.extensions?.at(0); +} + +/** + * Adds an extension to a file name that has none, recovering it from the file's MIME type. + * Some pickers hand us a name with no extension at all (an Android `content://` URI resolves to a bare + * numeric segment, and the document picker can return `name: null`), and such a file downloads as an + * extensionless blob the OS treats as a generic document even though the bytes are valid. + * The name is returned unchanged when it already has an extension or the MIME type can't be resolved, + * because a wrong extension is worse than no extension. + */ +function appendExtensionFromMimeType(fileName: string, mimeType: string | undefined): string { + if (!fileName || splitExtensionFromFileName(fileName).fileExtension) { + return fileName; + } + + const fileExtension = getExtensionFromMimeType(mimeType); + return fileExtension ? `${fileName}.${fileExtension}` : fileName; +} + /** * Returns the MIME type for a given file extension. * Falls back to 'application/octet-stream' for unrecognized extensions. @@ -970,6 +999,8 @@ export { showCameraPermissionsAlert, splitExtensionFromFileName, getMimeType, + getExtensionFromMimeType, + appendExtensionFromMimeType, getFileName, getDownloadFileName, getFileNameWithFallback, diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts index f643713e4b99..0066a371cb1b 100644 --- a/src/libs/validateAttachmentFile.ts +++ b/src/libs/validateAttachmentFile.ts @@ -3,7 +3,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 {appendExtensionFromMimeType, cleanFileName, hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils'; import snapshotPickedFile from './snapshotPickedFile'; type ValidateAttachmentValidResult = { @@ -62,13 +62,21 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}; } + /** + * Recovering a missing extension is done here so that it covers every upload flow (picker, drag and + * drop, copy-paste, share). An attachment stored without an extension downloads as a file the OS + * treats as a generic document, even when the bytes are a perfectly valid video or image. + * Receipts are unaffected: `isValidReceiptExtension` already rejected extensionless names above. + */ + const fileNameWithExtension = appendExtensionFromMimeType(normalizedFile.name ?? '', normalizedFile.type); + if (normalizedFile instanceof File) { /** * Cleaning file name, done here so that it covers all cases: * upload, drag and drop, copy-paste */ let updatedFile = normalizedFile; - const cleanName = cleanFileName(updatedFile.name); + const cleanName = cleanFileName(fileNameWithExtension); // 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 { @@ -91,6 +99,10 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, return {isValid: true, file: updatedFile}; } + if (fileNameWithExtension !== normalizedFile.name) { + return {isValid: true, file: {...normalizedFile, name: fileNameWithExtension}}; + } + return {isValid: true, file: normalizedFile}; } diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 3a65c61596ee..14665c3513ee 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -3,10 +3,12 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import DateUtils from '@libs/DateUtils'; import { ANDROID_SAFE_FILE_NAME_LENGTH, + appendExtensionFromMimeType, appendTimeToFileName, canvasFallback, getDownloadFileName, getExportFileName, + getExtensionFromMimeType, getFileNameWithFallback, getFileValidationErrorText, getImageDimensionsAfterResize, @@ -602,4 +604,57 @@ describe('FileUtils', () => { expect(getDownloadFileName('1234', 'https://staging.expensify.com/chat-attachments/123/w_9cb3daa')).toBe('1234'); }); }); + + describe('getExtensionFromMimeType', () => { + it('should return the primary extension registered for a known MIME type', () => { + // Given a MIME type mime-db knows + // When the extension is looked up + // Then the first registered extension is returned + expect(getExtensionFromMimeType('video/mp4')).toBe('mp4'); + expect(getExtensionFromMimeType('image/jpeg')).toBe('jpeg'); + expect(getExtensionFromMimeType('application/pdf')).toBe('pdf'); + }); + + it('should return undefined for an unknown or missing MIME type', () => { + // Given a MIME type that is unregistered, empty or undefined + // When the extension is looked up + // Then nothing is returned, so callers can decide their own fallback rather than guess wrong + expect(getExtensionFromMimeType('application/not-a-real-type')).toBeUndefined(); + expect(getExtensionFromMimeType('')).toBeUndefined(); + expect(getExtensionFromMimeType(undefined)).toBeUndefined(); + }); + }); + + describe('appendExtensionFromMimeType', () => { + it('should add the extension when the name has none', () => { + // Given an extensionless name, as an Android content:// URI or a null picker name produces + // When the MIME type is known + // Then the extension is recovered so the downloaded file is not a generic document + expect(appendExtensionFromMimeType('1000000042', 'video/mp4')).toBe('1000000042.mp4'); + expect(appendExtensionFromMimeType(CONST.DEFAULT_ATTACHMENT_FILENAME, 'video/mp4')).toBe(`${CONST.DEFAULT_ATTACHMENT_FILENAME}.mp4`); + }); + + it('should leave a name that already has an extension alone', () => { + // Given a name that already carries an extension + // When the MIME type disagrees with it + // Then the original name wins, because the picker's name is more trustworthy than the MIME type + expect(appendExtensionFromMimeType('recording.mov', 'video/mp4')).toBe('recording.mov'); + }); + + it('should leave the name alone when the MIME type cannot be resolved', () => { + // Given an extensionless name + // When the MIME type is unknown, empty or undefined + // Then no extension is invented, because a wrong extension is worse than none + expect(appendExtensionFromMimeType('1000000042', 'application/not-a-real-type')).toBe('1000000042'); + expect(appendExtensionFromMimeType('1000000042', '')).toBe('1000000042'); + expect(appendExtensionFromMimeType('1000000042', undefined)).toBe('1000000042'); + }); + + it('should return an empty name unchanged', () => { + // Given no name at all + // When an extension is requested + // Then nothing is produced, since a bare ".mp4" is not a usable file name + expect(appendExtensionFromMimeType('', 'video/mp4')).toBe(''); + }); + }); }); diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index 30a21fbc78fe..eac64b1a16ed 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -345,6 +345,91 @@ describe('validateAttachmentFile', () => { }); }); + describe('missing file extension', () => { + it('recovers the extension from the MIME type for a native file picked without one', async () => { + // Given a native attachment whose picker returned no extension (an Android content:// URI + // resolves to a bare numeric segment), but which does carry its MIME type + const file: FileObject = {name: '1000000042', size: 100, type: 'video/mp4', uri: 'content://media/external/video/media/42'}; + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then the extension is backfilled, so downloading it later produces a file the OS can open + // instead of an extensionless generic document + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe('1000000042.mp4'); + }); + + it('recovers the extension for the chat_attachment fallback name', async () => { + // Given a document picker that returned `name: null`, so getDataForUpload fell back to the + // literal default attachment name + const file: FileObject = {name: CONST.DEFAULT_ATTACHMENT_FILENAME, size: 100, type: 'video/mp4'}; + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then the default name gets a real extension + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe(`${CONST.DEFAULT_ATTACHMENT_FILENAME}.mp4`); + }); + + it('leaves the name untouched when the MIME type is unknown', async () => { + // Given an extensionless attachment whose MIME type cannot be mapped to an extension + const file: FileObject = {name: '1000000042', size: 100, type: 'application/not-a-real-type'}; + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then no extension is invented, because a wrong extension is worse than none + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe('1000000042'); + }); + + it('leaves a name that already has an extension untouched', async () => { + // Given an attachment that already carries an extension disagreeing with its MIME type + const file: FileObject = {name: 'recording.mov', size: 100, type: 'video/mp4'}; + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then the picker's own name is preserved + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe('recording.mov'); + }); + + it('recovers the extension on the web File path too', async () => { + // Given a web File with a real MIME type but no extension in its name + const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + try { + const file: FileObject = new File([new Blob(['content'], {type: 'video/mp4'})], 'screen_recording', {type: 'video/mp4'}); + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then the snapshotted File is renamed with the recovered extension + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe('screen_recording.mp4'); + } finally { + createObjectURLSpy.mockRestore(); + } + }); + }); + describe('object URL revocation', () => { it('revokes the superseded blob: uri and assigns the new object URL', async () => { // In Node/Jest the react-native-url-polyfill throws for createObjectURL (no BlobModule). From 68c430e113c77d1616ea03687499a5a618d1770d Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Mon, 21 Sep 2026 09:09:29 +0000 Subject: [PATCH 2/9] Reword comments to satisfy the spell checker Co-authored-by: Cong Pham --- src/libs/fileDownload/FileUtils.ts | 4 ++-- src/libs/validateAttachmentFile.ts | 2 +- tests/unit/FileUtilsTest.ts | 4 ++-- tests/unit/ValidateAttachmentFileTest.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 822cea272a5b..6ec67f952259 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -199,8 +199,8 @@ function getExtensionFromMimeType(mimeType: string | undefined): string | undefi /** * Adds an extension to a file name that has none, recovering it from the file's MIME type. * Some pickers hand us a name with no extension at all (an Android `content://` URI resolves to a bare - * numeric segment, and the document picker can return `name: null`), and such a file downloads as an - * extensionless blob the OS treats as a generic document even though the bytes are valid. + * numeric segment, and the document picker can return `name: null`), and such a file downloads as a + * bare blob the OS treats as a generic document even though the bytes are valid. * The name is returned unchanged when it already has an extension or the MIME type can't be resolved, * because a wrong extension is worse than no extension. */ diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts index 0066a371cb1b..11e00fab9fc0 100644 --- a/src/libs/validateAttachmentFile.ts +++ b/src/libs/validateAttachmentFile.ts @@ -66,7 +66,7 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, * Recovering a missing extension is done here so that it covers every upload flow (picker, drag and * drop, copy-paste, share). An attachment stored without an extension downloads as a file the OS * treats as a generic document, even when the bytes are a perfectly valid video or image. - * Receipts are unaffected: `isValidReceiptExtension` already rejected extensionless names above. + * Receipts are unaffected: `isValidReceiptExtension` already rejected names with no extension above. */ const fileNameWithExtension = appendExtensionFromMimeType(normalizedFile.name ?? '', normalizedFile.type); diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 14665c3513ee..48c7128cd119 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -627,7 +627,7 @@ describe('FileUtils', () => { describe('appendExtensionFromMimeType', () => { it('should add the extension when the name has none', () => { - // Given an extensionless name, as an Android content:// URI or a null picker name produces + // Given a name with no extension, as an Android content:// URI or a null picker name produces // When the MIME type is known // Then the extension is recovered so the downloaded file is not a generic document expect(appendExtensionFromMimeType('1000000042', 'video/mp4')).toBe('1000000042.mp4'); @@ -642,7 +642,7 @@ describe('FileUtils', () => { }); it('should leave the name alone when the MIME type cannot be resolved', () => { - // Given an extensionless name + // Given a name with no extension // When the MIME type is unknown, empty or undefined // Then no extension is invented, because a wrong extension is worse than none expect(appendExtensionFromMimeType('1000000042', 'application/not-a-real-type')).toBe('1000000042'); diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index eac64b1a16ed..e2047199f8b2 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -355,7 +355,7 @@ describe('validateAttachmentFile', () => { const result = await validateAttachmentFile(file); // Then the extension is backfilled, so downloading it later produces a file the OS can open - // instead of an extensionless generic document + // instead of a generic document with no extension expect(result.isValid).toBe(true); if (!result.isValid) { throw new Error('validateAttachmentFile should return a valid result'); @@ -380,7 +380,7 @@ describe('validateAttachmentFile', () => { }); it('leaves the name untouched when the MIME type is unknown', async () => { - // Given an extensionless attachment whose MIME type cannot be mapped to an extension + // Given an attachment with no extension whose MIME type cannot be mapped to one const file: FileObject = {name: '1000000042', size: 100, type: 'application/not-a-real-type'}; // When it is validated From 0c7a4e802b1ae6e351af7f84a815543269c1e00d Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Tue, 29 Sep 2026 16:02:29 +0000 Subject: [PATCH 3/9] Pass fileName to the inline video player so its Download uses the real name Co-authored-by: Cong Pham --- src/components/VideoPlayerPreview/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/VideoPlayerPreview/index.tsx b/src/components/VideoPlayerPreview/index.tsx index c806249ec049..4a8e8764341b 100644 --- a/src/components/VideoPlayerPreview/index.tsx +++ b/src/components/VideoPlayerPreview/index.tsx @@ -143,6 +143,7 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi Date: Wed, 30 Sep 2026 04:00:02 +0000 Subject: [PATCH 4/9] Detect the real MIME type from the file header for extensionless octet-stream attachments Co-authored-by: Cong Pham --- src/libs/fileDownload/FileUtils.ts | 15 ++- .../getMimeTypeFromHeaderBytes.ts | 69 ++++++++++++ .../getMimeTypeFromFileHeader/index.native.ts | 26 +++++ src/libs/getMimeTypeFromFileHeader/index.ts | 21 ++++ src/libs/snapshotPickedFile/index.native.ts | 8 +- src/libs/snapshotPickedFile/index.ts | 10 +- src/libs/validateAttachmentFile.ts | 28 ++++- tests/unit/FileUtilsTest.ts | 7 ++ tests/unit/ValidateAttachmentFileTest.ts | 76 +++++++++++++ tests/unit/getMimeTypeFromFileHeaderTest.ts | 100 ++++++++++++++++++ 10 files changed, 343 insertions(+), 17 deletions(-) create mode 100644 src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts create mode 100644 src/libs/getMimeTypeFromFileHeader/index.native.ts create mode 100644 src/libs/getMimeTypeFromFileHeader/index.ts create mode 100644 tests/unit/getMimeTypeFromFileHeaderTest.ts diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index c8269c487d47..eb0089c4705e 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -188,16 +188,24 @@ function getExtensionFromMimeType(mimeType: string | undefined): string | undefi return mimeDb[mimeType]?.extensions?.at(0); } +/** + * Whether a MIME type says nothing about the file's contents. Web reports `''` for a file it can't + * identify, and the Android document picker reports `application/octet-stream` for an extensionless file. + */ +function isUnknownMimeType(mimeType: string | undefined): boolean { + return !mimeType || mimeType === 'application/octet-stream'; +} + /** * Adds an extension to a file name that has none, recovering it from the file's MIME type. * Some pickers hand us a name with no extension at all (an Android `content://` URI resolves to a bare * numeric segment, and the document picker can return `name: null`), and such a file downloads as a * bare blob the OS treats as a generic document even though the bytes are valid. - * The name is returned unchanged when it already has an extension or the MIME type can't be resolved, - * because a wrong extension is worse than no extension. + * The name is returned unchanged when it already has an extension or the MIME type is unknown or can't be + * resolved, because a wrong or meaningless extension (such as `.bin`) is worse than no extension. */ function appendExtensionFromMimeType(fileName: string, mimeType: string | undefined): string { - if (!fileName || splitExtensionFromFileName(fileName).fileExtension) { + if (!fileName || isUnknownMimeType(mimeType) || splitExtensionFromFileName(fileName).fileExtension) { return fileName; } @@ -992,6 +1000,7 @@ export { splitExtensionFromFileName, getMimeType, getExtensionFromMimeType, + isUnknownMimeType, appendExtensionFromMimeType, getFileName, getFileNameWithFallback, diff --git a/src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts b/src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts new file mode 100644 index 000000000000..b4d3bd58d883 --- /dev/null +++ b/src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts @@ -0,0 +1,69 @@ +// cspell:ignore ftyp heix heim heis hevc hevx avis + +/** Number of leading bytes read from a file, enough to cover every signature below. */ +const FILE_HEADER_LENGTH = 32; + +/** + * ISO base media brands that are images rather than videos. HEIC/HEIF must keep its extensionless name so + * the HEIC conversion flow isn't bypassed, and AVIF shares the `ftyp` box with MP4. + */ +const IMAGE_FTYP_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1', 'avif', 'avis']); + +const AUDIO_FTYP_BRANDS = new Set(['M4A ', 'M4B ']); + +function hasBytes(bytes: Uint8Array, signature: number[], offset = 0): boolean { + return signature.every((byte, index) => bytes[offset + index] === byte); +} + +function readASCII(bytes: Uint8Array, offset: number, length: number): string { + return String.fromCharCode(...bytes.subarray(offset, offset + length)); +} + +function getMimeTypeFromFtypBrand(brand: string): string | undefined { + if (IMAGE_FTYP_BRANDS.has(brand)) { + return undefined; + } + if (AUDIO_FTYP_BRANDS.has(brand)) { + return 'audio/mp4'; + } + if (brand === 'qt ') { + return 'video/quicktime'; + } + if (brand.startsWith('3gp')) { + return 'video/3gpp'; + } + return 'video/mp4'; +} + +/** + * Identifies a file's MIME type from its leading bytes. Used when a file has neither an extension nor a + * meaningful MIME type, such as an extensionless file picked through the Android document picker. + * Returns undefined when no known signature matches. + */ +function getMimeTypeFromHeaderBytes(bytes: Uint8Array): string | undefined { + if (readASCII(bytes, 4, 4) === 'ftyp') { + return getMimeTypeFromFtypBrand(readASCII(bytes, 8, 4)); + } + if (hasBytes(bytes, [0x1a, 0x45, 0xdf, 0xa3])) { + return 'video/webm'; + } + if (hasBytes(bytes, [0xff, 0xd8, 0xff])) { + return 'image/jpeg'; + } + if (hasBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return 'image/png'; + } + if (readASCII(bytes, 0, 4) === 'GIF8') { + return 'image/gif'; + } + if (readASCII(bytes, 0, 4) === '%PDF') { + return 'application/pdf'; + } + if (readASCII(bytes, 0, 4) === 'RIFF' && readASCII(bytes, 8, 4) === 'WEBP') { + return 'image/webp'; + } + return undefined; +} + +export default getMimeTypeFromHeaderBytes; +export {FILE_HEADER_LENGTH}; diff --git a/src/libs/getMimeTypeFromFileHeader/index.native.ts b/src/libs/getMimeTypeFromFileHeader/index.native.ts new file mode 100644 index 000000000000..83e0d8a8d439 --- /dev/null +++ b/src/libs/getMimeTypeFromFileHeader/index.native.ts @@ -0,0 +1,26 @@ +import fileURIToPath from '@libs/fileURIToPath'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import {Buffer} from 'buffer'; +import RNFS from 'react-native-fs'; + +import getMimeTypeFromHeaderBytes, {FILE_HEADER_LENGTH} from './getMimeTypeFromHeaderBytes'; + +/** + * Reads only the first bytes of a picked file from its URI and identifies its MIME type from them. + * RNFS reads just the requested byte range, so this stays cheap for large videos. + * Resolves undefined when the file can't be read or no known signature matches. + */ +async function getMimeTypeFromFileHeader(file: FileObject): Promise { + if (typeof file.uri !== 'string') { + return undefined; + } + try { + const header = await RNFS.read(fileURIToPath(file.uri), FILE_HEADER_LENGTH, 0, 'base64'); + return getMimeTypeFromHeaderBytes(Buffer.from(header, 'base64')); + } catch { + return undefined; + } +} + +export default getMimeTypeFromFileHeader; diff --git a/src/libs/getMimeTypeFromFileHeader/index.ts b/src/libs/getMimeTypeFromFileHeader/index.ts new file mode 100644 index 000000000000..a3be76ce66c7 --- /dev/null +++ b/src/libs/getMimeTypeFromFileHeader/index.ts @@ -0,0 +1,21 @@ +import type {FileObject} from '@src/types/utils/Attachment'; + +import getMimeTypeFromHeaderBytes, {FILE_HEADER_LENGTH} from './getMimeTypeFromHeaderBytes'; + +/** + * Reads only the first bytes of a web File and identifies its MIME type from them. + * Resolves undefined when the file can't be read or no known signature matches. + */ +async function getMimeTypeFromFileHeader(file: FileObject): Promise { + if (!(file instanceof Blob)) { + return undefined; + } + try { + const header = await file.slice(0, FILE_HEADER_LENGTH).arrayBuffer(); + return getMimeTypeFromHeaderBytes(new Uint8Array(header)); + } catch { + return undefined; + } +} + +export default getMimeTypeFromFileHeader; diff --git a/src/libs/snapshotPickedFile/index.native.ts b/src/libs/snapshotPickedFile/index.native.ts index 3615900ad876..703c9c688ac4 100644 --- a/src/libs/snapshotPickedFile/index.native.ts +++ b/src/libs/snapshotPickedFile/index.native.ts @@ -1,10 +1,10 @@ /** * 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. + * File polyfill has no arrayBuffer. Only rename (or retype) when the cleaned name or the type differs. */ -function snapshotPickedFile(file: File, name: string): Promise { - if (file.name !== name) { - return Promise.resolve(new File([file], name, {type: file.type})); +function snapshotPickedFile(file: File, name: string, type = file.type): Promise { + if (file.name !== name || file.type !== type) { + return Promise.resolve(new File([file], name, {type})); } return Promise.resolve(file); } diff --git a/src/libs/snapshotPickedFile/index.ts b/src/libs/snapshotPickedFile/index.ts index 713118ecb696..51811ffdfaa6 100644 --- a/src/libs/snapshotPickedFile/index.ts +++ b/src/libs/snapshotPickedFile/index.ts @@ -12,17 +12,17 @@ function isIPadInDesktopMode(): boolean { * 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 { +async function snapshotPickedFile(file: File, name: string, type = file.type): 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. + // Safari — keep the lazy File there and only clean the name and type. if (isMobile() || isIPadInDesktopMode()) { - if (file.name !== name) { - return new File([file], name, {type: file.type}); + if (file.name !== name || file.type !== type) { + return new File([file], name, {type}); } return file; } - return new File([await file.arrayBuffer()], name, {type: file.type, lastModified: file.lastModified}); + return new File([await file.arrayBuffer()], name, {type, lastModified: file.lastModified}); } export default snapshotPickedFile; diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts index 11e00fab9fc0..dce4da33916c 100644 --- a/src/libs/validateAttachmentFile.ts +++ b/src/libs/validateAttachmentFile.ts @@ -3,7 +3,17 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {ValueOf} from 'type-fest'; -import {appendExtensionFromMimeType, cleanFileName, hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils'; +import { + appendExtensionFromMimeType, + cleanFileName, + hasHeicOrHeifExtension, + isUnknownMimeType, + isValidReceiptExtension, + normalizeFileObject, + splitExtensionFromFileName, + validateImageForCorruption, +} from './fileDownload/FileUtils'; +import getMimeTypeFromFileHeader from './getMimeTypeFromFileHeader'; import snapshotPickedFile from './snapshotPickedFile'; type ValidateAttachmentValidResult = { @@ -67,8 +77,16 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, * drop, copy-paste, share). An attachment stored without an extension downloads as a file the OS * treats as a generic document, even when the bytes are a perfectly valid video or image. * Receipts are unaffected: `isValidReceiptExtension` already rejected names with no extension above. + * When the MIME type is also unknown (the Android document picker reports `application/octet-stream` + * for an extensionless file, and web reports `''`), the real type is detected from the file's first bytes. */ - const fileNameWithExtension = appendExtensionFromMimeType(normalizedFile.name ?? '', normalizedFile.type); + const fileName = normalizedFile.name ?? ''; + let fileType = normalizedFile.type; + if (fileName && isUnknownMimeType(fileType) && !splitExtensionFromFileName(fileName).fileExtension) { + // Read from the pre-normalization object: on native it carries the uri, which normalizeFileObject drops. + fileType = (await getMimeTypeFromFileHeader(fileObject)) ?? fileType; + } + const fileNameWithExtension = appendExtensionFromMimeType(fileName, fileType); if (normalizedFile instanceof File) { /** @@ -80,7 +98,7 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, // 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); + updatedFile = await snapshotPickedFile(updatedFile, cleanName, fileType ?? updatedFile.type); } catch { // The backing file was already modified or deleted since it was picked. return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; @@ -99,8 +117,8 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, return {isValid: true, file: updatedFile}; } - if (fileNameWithExtension !== normalizedFile.name) { - return {isValid: true, file: {...normalizedFile, name: fileNameWithExtension}}; + if (fileNameWithExtension !== normalizedFile.name || fileType !== normalizedFile.type) { + return {isValid: true, file: {...normalizedFile, name: fileNameWithExtension, type: fileType}}; } return {isValid: true, file: normalizedFile}; diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 71fdb734a26c..947c99a65a03 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -621,6 +621,13 @@ describe('FileUtils', () => { expect(appendExtensionFromMimeType('1000000042', undefined)).toBe('1000000042'); }); + it('should leave the name alone for application/octet-stream instead of adding .bin', () => { + // Given a name with no extension whose MIME type is the generic one the Android document picker reports + // When the extension is recovered + // Then no meaningless .bin extension is added + expect(appendExtensionFromMimeType('1000000042', 'application/octet-stream')).toBe('1000000042'); + }); + it('should return an empty name unchanged', () => { // Given no name at all // When an extension is requested diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index e2047199f8b2..6fe9f5506fb4 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -4,8 +4,13 @@ import type {FileObject} from '@src/types/utils/Attachment'; import CONST from '../../src/CONST'; import * as FileUtils from '../../src/libs/fileDownload/FileUtils'; +import getMimeTypeFromFileHeader from '../../src/libs/getMimeTypeFromFileHeader'; import createMock from '../utils/createMock'; +jest.mock('@src/libs/getMimeTypeFromFileHeader', () => jest.fn()); + +const mockGetMimeTypeFromFileHeader = jest.mocked(getMimeTypeFromFileHeader); + // Mock only normalizeFileObject and validateImageForCorruption; keep real hasHeicOrHeifExtension and isValidReceiptExtension jest.mock('@src/libs/fileDownload/FileUtils', () => { const actual = jest.requireActual('@src/libs/fileDownload/FileUtils'); @@ -29,6 +34,7 @@ describe('validateAttachmentFile', () => { // Default: pass-through so async validation succeeds mockFileUtils.normalizeFileObject.mockImplementation(async (file) => file); mockFileUtils.validateImageForCorruption.mockResolvedValue(undefined); + mockGetMimeTypeFromFileHeader.mockResolvedValue(undefined); }); describe('FILE_INVALID', () => { @@ -428,6 +434,76 @@ describe('validateAttachmentFile', () => { createObjectURLSpy.mockRestore(); } }); + + it('detects the real type from the file header when the picker reports application/octet-stream', async () => { + // Given an extensionless MP4 from the Android document picker, which reports only the generic type + const file: FileObject = {name: 'recording', size: 100, type: 'application/octet-stream', uri: 'content://documents/document/42'}; + mockGetMimeTypeFromFileHeader.mockResolvedValue('video/mp4'); + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then the bytes are sniffed, and both the extension and the type come from the real format, + // so the download gets a .mp4 name and the optimistic message renders it as a video + expect(mockGetMimeTypeFromFileHeader).toHaveBeenCalledWith(file); + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe('recording.mp4'); + expect(result.file.type).toBe('video/mp4'); + }); + + it('leaves the name untouched when the header of an application/octet-stream file is not recognized', async () => { + // Given an extensionless file with the generic type whose header matches no known signature + const file: FileObject = {name: 'recording', size: 100, type: 'application/octet-stream', uri: 'content://documents/document/42'}; + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then no meaningless .bin extension is added and the type is kept + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe('recording'); + expect(result.file.type).toBe('application/octet-stream'); + }); + + it('does not read the file header when the MIME type or extension is already known', async () => { + // Given one file with a real MIME type and one with an extension + const fileWithType: FileObject = {name: 'recording', size: 100, type: 'video/mp4'}; + const fileWithExtension: FileObject = {name: 'recording.mp4', size: 100, type: 'application/octet-stream'}; + + // When they are validated + await validateAttachmentFile(fileWithType); + await validateAttachmentFile(fileWithExtension); + + // Then the header is never read, since the name can already be resolved + expect(mockGetMimeTypeFromFileHeader).not.toHaveBeenCalled(); + }); + + it('detects the real type on the web File path when the browser reports no type', async () => { + // Given a web File with no extension and an empty type, which browsers report for such files + const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + mockGetMimeTypeFromFileHeader.mockResolvedValue('video/mp4'); + try { + const file: FileObject = new File([new Blob(['content'])], 'screen_recording', {type: ''}); + + // When it is validated + const result = await validateAttachmentFile(file); + + // Then the snapshotted File gets the sniffed extension and type + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('validateAttachmentFile should return a valid result'); + } + expect(result.file.name).toBe('screen_recording.mp4'); + expect(result.file.type).toBe('video/mp4'); + } finally { + createObjectURLSpy.mockRestore(); + } + }); }); describe('object URL revocation', () => { diff --git a/tests/unit/getMimeTypeFromFileHeaderTest.ts b/tests/unit/getMimeTypeFromFileHeaderTest.ts new file mode 100644 index 000000000000..2d9d555587c5 --- /dev/null +++ b/tests/unit/getMimeTypeFromFileHeaderTest.ts @@ -0,0 +1,100 @@ +import getMimeTypeFromFileHeader from '@libs/getMimeTypeFromFileHeader/index.native'; +import getMimeTypeFromHeaderBytes from '@libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes'; + +import {Buffer} from 'buffer'; +import RNFS from 'react-native-fs'; + +// cspell:ignore ftyp isom WEBPVP WAVEfmt + +jest.mock('react-native-fs', () => ({ + read: jest.fn(), +})); + +const mockRead = jest.mocked(RNFS.read); + +/** Builds a header whose bytes 4-11 are an ISO base media `ftyp` box with the given brand. */ +function ftypHeader(brand: string): Uint8Array { + return Uint8Array.from([0x00, 0x00, 0x00, 0x20, ...Buffer.from(`ftyp${brand}`)]); +} + +describe('getMimeTypeFromHeaderBytes', () => { + it('identifies ISO base media files by their ftyp brand', () => { + // Given the headers of an MP4, a QuickTime movie, a 3GP video, and an M4A audio file + // When they are identified + // Then each maps to its own MIME type, and an unlisted brand is treated as MP4 + expect(getMimeTypeFromHeaderBytes(ftypHeader('isom'))).toBe('video/mp4'); + expect(getMimeTypeFromHeaderBytes(ftypHeader('mp42'))).toBe('video/mp4'); + expect(getMimeTypeFromHeaderBytes(ftypHeader('qt '))).toBe('video/quicktime'); + expect(getMimeTypeFromHeaderBytes(ftypHeader('3gp5'))).toBe('video/3gpp'); + expect(getMimeTypeFromHeaderBytes(ftypHeader('M4A '))).toBe('audio/mp4'); + }); + + it('does not identify HEIC or AVIF images as videos', () => { + // Given image formats that share the ftyp box with MP4 + // When they are identified + // Then no type is returned, so a HEIC file keeps its name and isn't mislabeled as a video + expect(getMimeTypeFromHeaderBytes(ftypHeader('heic'))).toBeUndefined(); + expect(getMimeTypeFromHeaderBytes(ftypHeader('mif1'))).toBeUndefined(); + expect(getMimeTypeFromHeaderBytes(ftypHeader('avif'))).toBeUndefined(); + }); + + it('identifies signatures at the start of the file', () => { + // Given the headers of WebM, JPEG, PNG, GIF, PDF, and WebP files + // When they are identified + // Then each maps to its MIME type + expect(getMimeTypeFromHeaderBytes(Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3, 0x01]))).toBe('video/webm'); + expect(getMimeTypeFromHeaderBytes(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]))).toBe('image/jpeg'); + expect(getMimeTypeFromHeaderBytes(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBe('image/png'); + expect(getMimeTypeFromHeaderBytes(Buffer.from('GIF89a'))).toBe('image/gif'); + expect(getMimeTypeFromHeaderBytes(Buffer.from('%PDF-1.7'))).toBe('application/pdf'); + expect(getMimeTypeFromHeaderBytes(Buffer.from('RIFF\u0000\u0000\u0000\u0000WEBPVP8 '))).toBe('image/webp'); + }); + + it('returns undefined for unknown or too-short headers', () => { + // Given a plain-text header, a RIFF file that is not WebP, and an empty header + // When they are identified + // Then no type is guessed + expect(getMimeTypeFromHeaderBytes(Buffer.from('hello world'))).toBeUndefined(); + expect(getMimeTypeFromHeaderBytes(Buffer.from('RIFF\u0000\u0000\u0000\u0000WAVEfmt '))).toBeUndefined(); + expect(getMimeTypeFromHeaderBytes(new Uint8Array())).toBeUndefined(); + }); +}); + +describe('getMimeTypeFromFileHeader (native)', () => { + beforeEach(() => { + mockRead.mockReset(); + }); + + it('reads only the first bytes of the file at its uri', async () => { + // Given an extensionless MP4 picked from a file:// uri + mockRead.mockResolvedValue(Buffer.from(ftypHeader('isom')).toString('base64')); + + // When its type is detected + const mimeType = await getMimeTypeFromFileHeader({name: 'recording', uri: 'file:///data/user/0/recording'}); + + // Then only a small byte range is read from the decoded path, and the type is identified + expect(mockRead).toHaveBeenCalledWith('/data/user/0/recording', 32, 0, 'base64'); + expect(mimeType).toBe('video/mp4'); + }); + + it('returns undefined when the file cannot be read', async () => { + // Given a uri the file system can't read + mockRead.mockRejectedValue(new Error('ENOENT')); + + // When its type is detected + const mimeType = await getMimeTypeFromFileHeader({name: 'recording', uri: 'content://missing/1'}); + + // Then detection fails quietly so validation can continue with the original type + expect(mimeType).toBeUndefined(); + }); + + it('returns undefined when the file has no uri', async () => { + // Given a file object without a uri + // When its type is detected + const mimeType = await getMimeTypeFromFileHeader({name: 'recording'}); + + // Then nothing is read + expect(mockRead).not.toHaveBeenCalled(); + expect(mimeType).toBeUndefined(); + }); +}); From 11a72290c957f8ca3cf3a64eabe62a984f69dcc9 Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Mon, 5 Oct 2026 16:13:18 +0000 Subject: [PATCH 5/9] Fix: Oxfmt formatting and add spellcheck words for attachment MIME detection Co-authored-by: Cong Pham --- cspell.json | 2 ++ src/libs/getMimeTypeFromFileHeader/index.native.ts | 1 + tests/unit/getMimeTypeFromFileHeaderTest.ts | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cspell.json b/cspell.json index 232fb5466088..ed5a4ab95178 100644 --- a/cspell.json +++ b/cspell.json @@ -662,6 +662,7 @@ "expensifynewkansas", "expensifyreactnative", "exportedto", + "extensionless", "fabs", "falso", "favicons", @@ -934,6 +935,7 @@ "QBWC", "qrcode", "quasis", + "quicktime", "quotepath", "rach", "reactnative", diff --git a/src/libs/getMimeTypeFromFileHeader/index.native.ts b/src/libs/getMimeTypeFromFileHeader/index.native.ts index 83e0d8a8d439..19a0e10aaadb 100644 --- a/src/libs/getMimeTypeFromFileHeader/index.native.ts +++ b/src/libs/getMimeTypeFromFileHeader/index.native.ts @@ -1,4 +1,5 @@ import fileURIToPath from '@libs/fileURIToPath'; + import type {FileObject} from '@src/types/utils/Attachment'; import {Buffer} from 'buffer'; diff --git a/tests/unit/getMimeTypeFromFileHeaderTest.ts b/tests/unit/getMimeTypeFromFileHeaderTest.ts index 2d9d555587c5..dee22c8f81f0 100644 --- a/tests/unit/getMimeTypeFromFileHeaderTest.ts +++ b/tests/unit/getMimeTypeFromFileHeaderTest.ts @@ -1,5 +1,5 @@ -import getMimeTypeFromFileHeader from '@libs/getMimeTypeFromFileHeader/index.native'; import getMimeTypeFromHeaderBytes from '@libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes'; +import getMimeTypeFromFileHeader from '@libs/getMimeTypeFromFileHeader/index.native'; import {Buffer} from 'buffer'; import RNFS from 'react-native-fs'; From 3cee69c72adfd5bb6be6b149cbe67f5beb85aa0c Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Tue, 6 Oct 2026 10:35:02 +0000 Subject: [PATCH 6/9] Split the file-header MIME sniffer into Android and iOS versions RNFS.read resolves an empty string on iOS for any positive length, so iOS now copies the header range into a temp file with blob-util and reads it back. Android keeps the RNFS.read implementation. Co-authored-by: Cong Pham --- .../{index.native.ts => index.android.ts} | 0 .../getMimeTypeFromFileHeader/index.ios.ts | 36 +++++++++ tests/unit/getMimeTypeFromFileHeaderTest.ts | 74 +++++++++++++++++-- 3 files changed, 105 insertions(+), 5 deletions(-) rename src/libs/getMimeTypeFromFileHeader/{index.native.ts => index.android.ts} (100%) create mode 100644 src/libs/getMimeTypeFromFileHeader/index.ios.ts diff --git a/src/libs/getMimeTypeFromFileHeader/index.native.ts b/src/libs/getMimeTypeFromFileHeader/index.android.ts similarity index 100% rename from src/libs/getMimeTypeFromFileHeader/index.native.ts rename to src/libs/getMimeTypeFromFileHeader/index.android.ts diff --git a/src/libs/getMimeTypeFromFileHeader/index.ios.ts b/src/libs/getMimeTypeFromFileHeader/index.ios.ts new file mode 100644 index 000000000000..28ac38abf0e7 --- /dev/null +++ b/src/libs/getMimeTypeFromFileHeader/index.ios.ts @@ -0,0 +1,36 @@ +import fileURIToPath from '@libs/fileURIToPath'; +import {rand64} from '@libs/NumberUtils'; + +import type {FileObject} from '@src/types/utils/Attachment'; + +import {Buffer} from 'buffer'; +import ReactNativeBlobUtil from 'react-native-blob-util'; + +import getMimeTypeFromHeaderBytes, {FILE_HEADER_LENGTH} from './getMimeTypeFromHeaderBytes'; + +/** + * Reads only the first bytes of a picked file from its URI and identifies its MIME type from them. + * RNFS.read resolves an empty string on iOS, so the header range is copied into a temp file and read back instead. + * Resolves undefined when the file can't be read or no known signature matches. + */ +async function getMimeTypeFromFileHeader(file: FileObject): Promise { + if (typeof file.uri !== 'string') { + return undefined; + } + const tempPath = `${ReactNativeBlobUtil.fs.dirs.CacheDir}/mime-sniff-head-${rand64()}`; + try { + // blob-util rejects file:// URIs on iOS, so it needs the bare path + await ReactNativeBlobUtil.fs.slice(fileURIToPath(file.uri), tempPath, 0, FILE_HEADER_LENGTH); + const header: unknown = await ReactNativeBlobUtil.fs.readFile(tempPath, 'base64'); + if (typeof header !== 'string') { + return undefined; + } + return getMimeTypeFromHeaderBytes(Buffer.from(header, 'base64')); + } catch { + return undefined; + } finally { + ReactNativeBlobUtil.fs.unlink(tempPath).catch(() => {}); + } +} + +export default getMimeTypeFromFileHeader; diff --git a/tests/unit/getMimeTypeFromFileHeaderTest.ts b/tests/unit/getMimeTypeFromFileHeaderTest.ts index dee22c8f81f0..2ed36b37e239 100644 --- a/tests/unit/getMimeTypeFromFileHeaderTest.ts +++ b/tests/unit/getMimeTypeFromFileHeaderTest.ts @@ -1,5 +1,8 @@ import getMimeTypeFromHeaderBytes from '@libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes'; -import getMimeTypeFromFileHeader from '@libs/getMimeTypeFromFileHeader/index.native'; +import getMimeTypeFromFileHeaderAndroid from '@libs/getMimeTypeFromFileHeader/index.android'; +import getMimeTypeFromFileHeaderIOS from '@libs/getMimeTypeFromFileHeader/index.ios'; + +import type ReactNativeBlobUtil from 'react-native-blob-util'; import {Buffer} from 'buffer'; import RNFS from 'react-native-fs'; @@ -10,6 +13,20 @@ jest.mock('react-native-fs', () => ({ read: jest.fn(), })); +type BlobUtilFs = typeof ReactNativeBlobUtil.fs; +const mockSlice = jest.fn, Parameters>(); +const mockReadFile = jest.fn, [string, string]>(); +const mockUnlink = jest.fn, Parameters>(); + +jest.mock('react-native-blob-util', () => ({ + fs: { + dirs: {CacheDir: '/cache'}, + slice: (...args: Parameters) => mockSlice(...args), + readFile: (...args: [string, string]) => mockReadFile(...args), + unlink: (...args: Parameters) => mockUnlink(...args), + }, +})); + const mockRead = jest.mocked(RNFS.read); /** Builds a header whose bytes 4-11 are an ISO base media `ftyp` box with the given brand. */ @@ -60,7 +77,7 @@ describe('getMimeTypeFromHeaderBytes', () => { }); }); -describe('getMimeTypeFromFileHeader (native)', () => { +describe('getMimeTypeFromFileHeader (Android)', () => { beforeEach(() => { mockRead.mockReset(); }); @@ -70,7 +87,7 @@ describe('getMimeTypeFromFileHeader (native)', () => { mockRead.mockResolvedValue(Buffer.from(ftypHeader('isom')).toString('base64')); // When its type is detected - const mimeType = await getMimeTypeFromFileHeader({name: 'recording', uri: 'file:///data/user/0/recording'}); + const mimeType = await getMimeTypeFromFileHeaderAndroid({name: 'recording', uri: 'file:///data/user/0/recording'}); // Then only a small byte range is read from the decoded path, and the type is identified expect(mockRead).toHaveBeenCalledWith('/data/user/0/recording', 32, 0, 'base64'); @@ -82,7 +99,7 @@ describe('getMimeTypeFromFileHeader (native)', () => { mockRead.mockRejectedValue(new Error('ENOENT')); // When its type is detected - const mimeType = await getMimeTypeFromFileHeader({name: 'recording', uri: 'content://missing/1'}); + const mimeType = await getMimeTypeFromFileHeaderAndroid({name: 'recording', uri: 'content://missing/1'}); // Then detection fails quietly so validation can continue with the original type expect(mimeType).toBeUndefined(); @@ -91,10 +108,57 @@ describe('getMimeTypeFromFileHeader (native)', () => { it('returns undefined when the file has no uri', async () => { // Given a file object without a uri // When its type is detected - const mimeType = await getMimeTypeFromFileHeader({name: 'recording'}); + const mimeType = await getMimeTypeFromFileHeaderAndroid({name: 'recording'}); // Then nothing is read expect(mockRead).not.toHaveBeenCalled(); expect(mimeType).toBeUndefined(); }); }); + +describe('getMimeTypeFromFileHeader (iOS)', () => { + beforeEach(() => { + mockSlice.mockReset(); + mockReadFile.mockReset(); + mockUnlink.mockReset(); + mockUnlink.mockResolvedValue(); + }); + + it('copies the first bytes into a temp file, reads them back, and removes the temp file', async () => { + // Given an extensionless QuickTime movie picked from a file:// uri + mockSlice.mockImplementation((src, dest) => Promise.resolve(dest)); + mockReadFile.mockResolvedValue(Buffer.from(ftypHeader('qt ')).toString('base64')); + + // When its type is detected + const mimeType = await getMimeTypeFromFileHeaderIOS({name: 'recording', uri: 'file:///var/mobile/recording'}); + + // Then only a small byte range is copied from the decoded path into the cache, read back, and cleaned up + expect(mockSlice).toHaveBeenCalledWith('/var/mobile/recording', expect.stringMatching(/^\/cache\/mime-sniff-head-/), 0, 32); + const tempPath = mockSlice.mock.calls.at(0)?.at(1); + expect(mockReadFile).toHaveBeenCalledWith(tempPath, 'base64'); + expect(mockUnlink).toHaveBeenCalledWith(tempPath); + expect(mimeType).toBe('video/quicktime'); + }); + + it('returns undefined when the file cannot be read', async () => { + // Given a uri the file system can't copy from + mockSlice.mockRejectedValue(new Error('ENOENT')); + + // When its type is detected + const mimeType = await getMimeTypeFromFileHeaderIOS({name: 'recording', uri: 'file:///var/mobile/missing'}); + + // Then detection fails quietly so validation can continue with the original type + expect(mockReadFile).not.toHaveBeenCalled(); + expect(mimeType).toBeUndefined(); + }); + + it('returns undefined when the file has no uri', async () => { + // Given a file object without a uri + // When its type is detected + const mimeType = await getMimeTypeFromFileHeaderIOS({name: 'recording'}); + + // Then nothing is copied + expect(mockSlice).not.toHaveBeenCalled(); + expect(mimeType).toBeUndefined(); + }); +}); From 8620e57c60603dd8434a60980dbe0c61b760ea69 Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Wed, 7 Oct 2026 15:35:29 +0000 Subject: [PATCH 7/9] Use words instead of an arrow in the getExtensionFromMimeType comment Co-authored-by: Cong Pham --- src/libs/fileDownload/FileUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index eb0089c4705e..839c68f8bb4b 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -178,7 +178,7 @@ function getFileNameWithFallback(fileName: string | null | undefined, uri: strin } /** - * Returns the most common file extension registered for a MIME type, e.g. `video/mp4` -> `mp4`. + * Returns the most common file extension registered for a MIME type, e.g. `video/mp4` resolves to `mp4`. * Returns undefined for an empty or unrecognized MIME type so callers can pick their own fallback. */ function getExtensionFromMimeType(mimeType: string | undefined): string | undefined { From 165cbacb14dbbb35f015e375a1cf0fe28106f81c Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Thu, 8 Oct 2026 02:59:16 +0000 Subject: [PATCH 8/9] Log a warning when the file header can't be read instead of ignoring the error Co-authored-by: Cong Pham --- src/libs/getMimeTypeFromFileHeader/index.android.ts | 4 +++- src/libs/getMimeTypeFromFileHeader/index.ios.ts | 8 ++++++-- src/libs/getMimeTypeFromFileHeader/index.ts | 5 ++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/libs/getMimeTypeFromFileHeader/index.android.ts b/src/libs/getMimeTypeFromFileHeader/index.android.ts index 19a0e10aaadb..a7d4d65c2ff4 100644 --- a/src/libs/getMimeTypeFromFileHeader/index.android.ts +++ b/src/libs/getMimeTypeFromFileHeader/index.android.ts @@ -1,4 +1,5 @@ import fileURIToPath from '@libs/fileURIToPath'; +import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -19,7 +20,8 @@ async function getMimeTypeFromFileHeader(file: FileObject): Promise {}); + ReactNativeBlobUtil.fs.unlink(tempPath).catch((error: unknown) => { + Log.warn('[getMimeTypeFromFileHeader] Could not delete temp header file', {error: error instanceof Error ? error.message : String(error)}); + }); } } diff --git a/src/libs/getMimeTypeFromFileHeader/index.ts b/src/libs/getMimeTypeFromFileHeader/index.ts index a3be76ce66c7..e4890ec22258 100644 --- a/src/libs/getMimeTypeFromFileHeader/index.ts +++ b/src/libs/getMimeTypeFromFileHeader/index.ts @@ -1,3 +1,5 @@ +import Log from '@libs/Log'; + import type {FileObject} from '@src/types/utils/Attachment'; import getMimeTypeFromHeaderBytes, {FILE_HEADER_LENGTH} from './getMimeTypeFromHeaderBytes'; @@ -13,7 +15,8 @@ async function getMimeTypeFromFileHeader(file: FileObject): Promise Date: Fri, 9 Oct 2026 10:38:09 +0000 Subject: [PATCH 9/9] Map QuickTime to mov and only treat allowlisted ftyp brands as MP4 Co-authored-by: Cong Pham --- src/libs/fileDownload/FileUtils.ts | 8 +++++++- .../getMimeTypeFromHeaderBytes.ts | 15 ++++++++------- tests/unit/FileUtilsTest.ts | 7 +++++++ tests/unit/getMimeTypeFromFileHeaderTest.ts | 11 ++++++++++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 839c68f8bb4b..f316ddc65b72 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -177,6 +177,12 @@ function getFileNameWithFallback(fileName: string | null | undefined, uri: strin return fileNameFromURI; } +/** + * Extensions to use instead of mime-db's first registered one. mime-db lists `video/quicktime` as + * `qt` first, but `Str.isVideo` only recognizes `mov`, so a `.qt` file would render as a generic file. + */ +const MIME_TYPE_EXTENSION_OVERRIDES = new Map([['video/quicktime', 'mov']]); + /** * Returns the most common file extension registered for a MIME type, e.g. `video/mp4` resolves to `mp4`. * Returns undefined for an empty or unrecognized MIME type so callers can pick their own fallback. @@ -185,7 +191,7 @@ function getExtensionFromMimeType(mimeType: string | undefined): string | undefi if (!mimeType) { return undefined; } - return mimeDb[mimeType]?.extensions?.at(0); + return MIME_TYPE_EXTENSION_OVERRIDES.get(mimeType) ?? mimeDb[mimeType]?.extensions?.at(0); } /** diff --git a/src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts b/src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts index b4d3bd58d883..315994426a27 100644 --- a/src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts +++ b/src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts @@ -1,13 +1,14 @@ -// cspell:ignore ftyp heix heim heis hevc hevx avis +// cspell:ignore ftyp isom mmp4 /** Number of leading bytes read from a file, enough to cover every signature below. */ const FILE_HEADER_LENGTH = 32; /** - * ISO base media brands that are images rather than videos. HEIC/HEIF must keep its extensionless name so - * the HEIC conversion flow isn't bypassed, and AVIF shares the `ftyp` box with MP4. + * ISO base media brands known to be MP4 videos. Any other brand returns no type, because image formats + * such as HEIC and AVIF share the `ftyp` box with MP4, and a wrong extension is worse than none. HEIC in + * particular must keep its extensionless name so the HEIC conversion flow isn't bypassed. */ -const IMAGE_FTYP_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1', 'avif', 'avis']); +const MP4_FTYP_BRANDS = new Set(['isom', 'iso2', 'iso3', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'avc1', 'dash', 'M4V ', 'mmp4']); const AUDIO_FTYP_BRANDS = new Set(['M4A ', 'M4B ']); @@ -20,8 +21,8 @@ function readASCII(bytes: Uint8Array, offset: number, length: number): string { } function getMimeTypeFromFtypBrand(brand: string): string | undefined { - if (IMAGE_FTYP_BRANDS.has(brand)) { - return undefined; + if (MP4_FTYP_BRANDS.has(brand)) { + return 'video/mp4'; } if (AUDIO_FTYP_BRANDS.has(brand)) { return 'audio/mp4'; @@ -32,7 +33,7 @@ function getMimeTypeFromFtypBrand(brand: string): string | undefined { if (brand.startsWith('3gp')) { return 'video/3gpp'; } - return 'video/mp4'; + return undefined; } /** diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 947c99a65a03..5373a3c19229 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -586,6 +586,13 @@ describe('FileUtils', () => { expect(getExtensionFromMimeType('application/pdf')).toBe('pdf'); }); + it('should return mov for a QuickTime video', () => { + // Given the QuickTime MIME type, which mime-db lists as `qt` first + // When the extension is looked up + // Then `mov` is returned, because `Str.isVideo` doesn't recognize `qt` and the file would render as a generic file + expect(getExtensionFromMimeType('video/quicktime')).toBe('mov'); + }); + it('should return undefined for an unknown or missing MIME type', () => { // Given a MIME type that is unregistered, empty or undefined // When the extension is looked up diff --git a/tests/unit/getMimeTypeFromFileHeaderTest.ts b/tests/unit/getMimeTypeFromFileHeaderTest.ts index 2ed36b37e239..f5e5b06cfc59 100644 --- a/tests/unit/getMimeTypeFromFileHeaderTest.ts +++ b/tests/unit/getMimeTypeFromFileHeaderTest.ts @@ -38,9 +38,10 @@ describe('getMimeTypeFromHeaderBytes', () => { it('identifies ISO base media files by their ftyp brand', () => { // Given the headers of an MP4, a QuickTime movie, a 3GP video, and an M4A audio file // When they are identified - // Then each maps to its own MIME type, and an unlisted brand is treated as MP4 + // Then each maps to its own MIME type expect(getMimeTypeFromHeaderBytes(ftypHeader('isom'))).toBe('video/mp4'); expect(getMimeTypeFromHeaderBytes(ftypHeader('mp42'))).toBe('video/mp4'); + expect(getMimeTypeFromHeaderBytes(ftypHeader('M4V '))).toBe('video/mp4'); expect(getMimeTypeFromHeaderBytes(ftypHeader('qt '))).toBe('video/quicktime'); expect(getMimeTypeFromHeaderBytes(ftypHeader('3gp5'))).toBe('video/3gpp'); expect(getMimeTypeFromHeaderBytes(ftypHeader('M4A '))).toBe('audio/mp4'); @@ -55,6 +56,14 @@ describe('getMimeTypeFromHeaderBytes', () => { expect(getMimeTypeFromHeaderBytes(ftypHeader('avif'))).toBeUndefined(); }); + it('returns undefined for an unknown ftyp brand', () => { + // Given an ftyp brand that isn't a known MP4, QuickTime, 3GP, or M4A brand + // When it is identified + // Then no type is returned, because guessing MP4 could give an image or audio file a wrong `.mp4` extension + expect(getMimeTypeFromHeaderBytes(ftypHeader('abcd'))).toBeUndefined(); + expect(getMimeTypeFromHeaderBytes(ftypHeader('crx '))).toBeUndefined(); + }); + it('identifies signatures at the start of the file', () => { // Given the headers of WebM, JPEG, PNG, GIF, PDF, and WebP files // When they are identified