file-type
Version:
Detect the file type of a file, stream, or data
431 lines (375 loc) • 13.5 kB
JavaScript
import * as strtok3 from 'strtok3/core';
import {ZipHandler, ZipLimitError} from '@tokenizer/inflate';
import {
maximumUntrustedSkipSizeInBytes,
ParserHardLimitError,
hasUnknownFileSize,
} from '../parser.js';
const maximumZipEntrySizeInBytes = 1024 * 1024;
const maximumZipEntryCount = 1024;
const maximumZipTextEntrySizeInBytes = maximumZipEntrySizeInBytes;
const recoverableZipErrorMessages = new Set([
'Unexpected signature',
'Encrypted ZIP',
'Expected Central-File-Header signature',
]);
const recoverableZipErrorMessagePrefixes = [
'Unsupported ZIP compression method:',
'Expected data-descriptor-signature at position ',
];
const recoverableZipErrorCodes = new Set([
'Z_BUF_ERROR',
'Z_DATA_ERROR',
'ERR_INVALID_STATE',
]);
function isRecoverableZipError(error) {
if (error instanceof strtok3.EndOfStreamError) {
return true;
}
if (error instanceof ParserHardLimitError || error instanceof ZipLimitError) {
return true;
}
if (!(error instanceof Error)) {
return false;
}
if (error instanceof TypeError && error.message.startsWith('Failed to deflate ZIP entry:')) {
return true;
}
if (recoverableZipErrorMessages.has(error.message)) {
return true;
}
if (recoverableZipErrorCodes.has(error.code)) {
return true;
}
for (const prefix of recoverableZipErrorMessagePrefixes) {
if (error.message.startsWith(prefix)) {
return true;
}
}
return false;
}
function isZipScanLimitError(error) {
return error instanceof ZipLimitError && [
'maxTotalScanBytes',
'maxTotalDataDescriptorScanBytes',
'maxDataDescriptorScanBytes',
'maxSkippedEntrySize',
].includes(error.limit);
}
function canReadZipEntryForDetection(zipHeader, maximumSize = maximumZipEntrySizeInBytes) {
const sizes = [zipHeader.compressedSize, zipHeader.uncompressedSize];
for (const size of sizes) {
if (
!Number.isFinite(size)
|| size < 0
|| size > maximumSize
) {
return false;
}
}
return true;
}
// -- iWork helpers --
function createIWorkZipDetectionState() {
return {
hasDocumentEntry: false,
hasMasterSlideEntry: false,
hasTablesEntry: false,
hasCalculationEngineEntry: false,
};
}
function updateIWorkZipDetectionStateFromFilename(iWorkState, filename) {
if (filename === 'Index/Document.iwa') {
iWorkState.hasDocumentEntry = true;
}
if (filename.startsWith('Index/MasterSlide')) {
iWorkState.hasMasterSlideEntry = true;
}
if (filename.startsWith('Index/Tables/')) {
iWorkState.hasTablesEntry = true;
}
if (filename === 'Index/CalculationEngine.iwa') {
iWorkState.hasCalculationEngineEntry = true;
}
}
function getIWorkFileTypeFromZipEntries(iWorkState) {
if (!iWorkState.hasDocumentEntry) {
return;
}
if (iWorkState.hasMasterSlideEntry) {
return {ext: 'key', mime: 'application/vnd.apple.keynote'};
}
if (iWorkState.hasTablesEntry) {
return {ext: 'numbers', mime: 'application/vnd.apple.numbers'};
}
return {ext: 'pages', mime: 'application/vnd.apple.pages'};
}
// -- OpenXML helpers --
function getFileTypeFromMimeType(mimeType) {
mimeType = mimeType.toLowerCase();
switch (mimeType) {
case 'application/epub+zip':
return {ext: 'epub', mime: mimeType};
case 'application/vnd.oasis.opendocument.text':
return {ext: 'odt', mime: mimeType};
case 'application/vnd.oasis.opendocument.text-template':
return {ext: 'ott', mime: mimeType};
case 'application/vnd.oasis.opendocument.spreadsheet':
return {ext: 'ods', mime: mimeType};
case 'application/vnd.oasis.opendocument.spreadsheet-template':
return {ext: 'ots', mime: mimeType};
case 'application/vnd.oasis.opendocument.presentation':
return {ext: 'odp', mime: mimeType};
case 'application/vnd.oasis.opendocument.presentation-template':
return {ext: 'otp', mime: mimeType};
case 'application/vnd.oasis.opendocument.graphics':
return {ext: 'odg', mime: mimeType};
case 'application/vnd.oasis.opendocument.graphics-template':
return {ext: 'otg', mime: mimeType};
case 'application/vnd.openxmlformats-officedocument.presentationml.slideshow':
return {ext: 'ppsx', mime: mimeType};
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return {ext: 'xlsx', mime: mimeType};
case 'application/vnd.ms-excel.sheet.macroenabled':
return {ext: 'xlsm', mime: 'application/vnd.ms-excel.sheet.macroenabled.12'};
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.template':
return {ext: 'xltx', mime: mimeType};
case 'application/vnd.ms-excel.template.macroenabled':
return {ext: 'xltm', mime: 'application/vnd.ms-excel.template.macroenabled.12'};
case 'application/vnd.ms-powerpoint.slideshow.macroenabled':
return {ext: 'ppsm', mime: 'application/vnd.ms-powerpoint.slideshow.macroenabled.12'};
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return {ext: 'docx', mime: mimeType};
case 'application/vnd.ms-word.document.macroenabled':
return {ext: 'docm', mime: 'application/vnd.ms-word.document.macroenabled.12'};
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.template':
return {ext: 'dotx', mime: mimeType};
case 'application/vnd.ms-word.template.macroenabledtemplate':
return {ext: 'dotm', mime: 'application/vnd.ms-word.template.macroenabled.12'};
case 'application/vnd.openxmlformats-officedocument.presentationml.template':
return {ext: 'potx', mime: mimeType};
case 'application/vnd.ms-powerpoint.template.macroenabled':
return {ext: 'potm', mime: 'application/vnd.ms-powerpoint.template.macroenabled.12'};
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
return {ext: 'pptx', mime: mimeType};
case 'application/vnd.ms-powerpoint.presentation.macroenabled':
return {ext: 'pptm', mime: 'application/vnd.ms-powerpoint.presentation.macroenabled.12'};
case 'application/vnd.ms-visio.drawing':
return {ext: 'vsdx', mime: 'application/vnd.visio'};
case 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml':
return {ext: '3mf', mime: 'model/3mf'};
default:
}
}
function createOpenXmlZipDetectionState() {
return {
hasContentTypesEntry: false,
hasParsedContentTypesEntry: false,
isParsingContentTypes: false,
hasUnparseableContentTypes: false,
hasWordDirectory: false,
hasPresentationDirectory: false,
hasSpreadsheetDirectory: false,
hasThreeDimensionalModelEntry: false,
};
}
function updateOpenXmlZipDetectionStateFromFilename(openXmlState, filename) {
if (filename.startsWith('word/')) {
openXmlState.hasWordDirectory = true;
}
if (filename.startsWith('ppt/')) {
openXmlState.hasPresentationDirectory = true;
}
if (filename.startsWith('xl/')) {
openXmlState.hasSpreadsheetDirectory = true;
}
if (
filename.startsWith('3D/')
&& filename.endsWith('.model')
) {
openXmlState.hasThreeDimensionalModelEntry = true;
}
}
function getOpenXmlFileTypeFromDirectoryNames(openXmlState) {
if (openXmlState.hasWordDirectory) {
return {
ext: 'docx',
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};
}
if (openXmlState.hasPresentationDirectory) {
return {
ext: 'pptx',
mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
};
}
if (openXmlState.hasSpreadsheetDirectory) {
return {
ext: 'xlsx',
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
}
if (openXmlState.hasThreeDimensionalModelEntry) {
return {
ext: '3mf',
mime: 'model/3mf',
};
}
}
function getOpenXmlFileTypeFromZipEntries(openXmlState) {
// Only use directory-name heuristic when [Content_Types].xml was present in the archive
// but its handler was skipped (not invoked, not currently running, and not already resolved).
// This avoids guessing from directory names when content-type parsing already gave a definitive answer or failed.
if (
!openXmlState.hasContentTypesEntry
|| openXmlState.hasUnparseableContentTypes
|| openXmlState.isParsingContentTypes
|| openXmlState.hasParsedContentTypesEntry
) {
return;
}
return getOpenXmlFileTypeFromDirectoryNames(openXmlState);
}
function getOpenXmlMimeTypeFromContentTypesXml(xmlContent) {
// We only need the `ContentType="...main+xml"` value, so a small string scan is enough and avoids full XML parsing.
const endPosition = xmlContent.indexOf('.main+xml"');
if (endPosition === -1) {
const mimeType = 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml';
if (xmlContent.includes(`ContentType="${mimeType}"`)) {
return mimeType;
}
return;
}
const truncatedContent = xmlContent.slice(0, endPosition);
const firstQuotePosition = truncatedContent.lastIndexOf('"');
// If no quote is found, `lastIndexOf` returns -1 and this intentionally falls back to the full truncated prefix.
return truncatedContent.slice(firstQuotePosition + 1);
}
export async function detectZip(tokenizer) {
let fileType;
const openXmlState = createOpenXmlZipDetectionState();
const iWorkState = createIWorkZipDetectionState();
try {
await new ZipHandler(tokenizer, {
// Keep local-header order and partial-archive detection consistent across input types.
useCentralDirectory: false,
allowTruncatedDataDescriptor: true,
maxEntryCount: maximumZipEntryCount,
// Unknown-size input is bounded before each read, including entry metadata.
maxTotalScanBytes: hasUnknownFileSize(tokenizer) ? maximumUntrustedSkipSizeInBytes : Infinity,
maxTotalDataDescriptorScanBytes: hasUnknownFileSize(tokenizer) ? Infinity : maximumZipEntrySizeInBytes,
maxSkippedEntrySize: hasUnknownFileSize(tokenizer) ? maximumZipEntrySizeInBytes : Infinity,
maxDataDescriptorScanBytes: maximumZipEntrySizeInBytes,
maxCompressedSize: maximumZipTextEntrySizeInBytes,
maxUncompressedSize: maximumZipTextEntrySizeInBytes,
}).unzip(zipHeader => {
updateOpenXmlZipDetectionStateFromFilename(openXmlState, zipHeader.filename);
updateIWorkZipDetectionStateFromFilename(iWorkState, zipHeader.filename);
// Early exit for Keynote or Numbers when markers are definitive
if (iWorkState.hasDocumentEntry && (iWorkState.hasMasterSlideEntry || iWorkState.hasTablesEntry)) {
fileType = getIWorkFileTypeFromZipEntries(iWorkState);
return {stop: true};
}
const isOpenXmlContentTypesEntry = zipHeader.filename === '[Content_Types].xml';
const openXmlFileTypeFromEntries = getOpenXmlFileTypeFromZipEntries(openXmlState);
if (
!isOpenXmlContentTypesEntry
&& openXmlFileTypeFromEntries
) {
fileType = openXmlFileTypeFromEntries;
return {
stop: true,
};
}
switch (zipHeader.filename) {
case 'META-INF/mozilla.rsa':
fileType = {
ext: 'xpi',
mime: 'application/x-xpinstall',
};
return {
stop: true,
};
case 'META-INF/MANIFEST.MF':
fileType = {
ext: 'jar',
mime: 'application/java-archive',
};
return {
stop: true,
};
case 'mimetype':
if (!canReadZipEntryForDetection(zipHeader, maximumZipTextEntrySizeInBytes)) {
return {};
}
return {
async handler(fileData) {
// Use TextDecoder to decode the UTF-8 encoded data
const mimeType = new TextDecoder('utf-8').decode(fileData).trim();
fileType = getFileTypeFromMimeType(mimeType);
},
stop: true,
};
case '[Content_Types].xml': {
openXmlState.hasContentTypesEntry = true;
if (!canReadZipEntryForDetection(zipHeader, maximumZipTextEntrySizeInBytes)) {
openXmlState.hasUnparseableContentTypes = true;
return {};
}
openXmlState.isParsingContentTypes = true;
return {
async handler(fileData) {
// Use TextDecoder to decode the UTF-8 encoded data
const xmlContent = new TextDecoder('utf-8').decode(fileData);
const mimeType = getOpenXmlMimeTypeFromContentTypesXml(xmlContent);
if (mimeType) {
fileType = getFileTypeFromMimeType(mimeType);
}
openXmlState.hasParsedContentTypesEntry = true;
openXmlState.isParsingContentTypes = false;
},
stop: true,
};
}
default:
if (/classes\d*\.dex/v.test(zipHeader.filename)) {
fileType = {
ext: 'apk',
mime: 'application/vnd.android.package-archive',
};
return {stop: true};
}
return {};
}
});
} catch (error) {
if (!isRecoverableZipError(error)) {
throw error;
}
if (openXmlState.isParsingContentTypes) {
openXmlState.isParsingContentTypes = false;
openXmlState.hasUnparseableContentTypes = true;
}
// Producers are free to put [Content_Types].xml after the content entries, so running out of
// input or budget before reaching it says nothing about whether this is an OOXML file.
// Guess from the directory names instead.
if (
!fileType
&& (error instanceof strtok3.EndOfStreamError || error instanceof ParserHardLimitError || isZipScanLimitError(error))
&& !openXmlState.hasContentTypesEntry
) {
fileType = getOpenXmlFileTypeFromDirectoryNames(openXmlState);
}
}
const iWorkFileType = hasUnknownFileSize(tokenizer)
&& iWorkState.hasDocumentEntry
&& !iWorkState.hasMasterSlideEntry
&& !iWorkState.hasTablesEntry
&& !iWorkState.hasCalculationEngineEntry
? undefined
: getIWorkFileTypeFromZipEntries(iWorkState);
return fileType ?? getOpenXmlFileTypeFromZipEntries(openXmlState) ?? iWorkFileType ?? {
ext: 'zip',
mime: 'application/zip',
};
}