fix(common): report the detected file type in file type errors (#18289)

This commit is contained in:
Denis Scarabelli authored and GitHub committed 2026-10-27 08:44:07 +01:00
1 parent 870c54b44d
commit f991703156
2 files changed
+121 -1

No files matched your search

@@ -81,6 +81,14 @@ export class FileTypeValidator extends FileValidator<
FileTypeValidatorOptions,
IFile
> {
/**
* The type detected from the content of each validated file (`null` when
* none was), so that the error message can report the type that was
* actually checked rather than the one the client declared. Keyed by file,
* as the validator is shared by concurrent uploads.
*/
private readonly detectedMimeTypes = new WeakMap<IFile, string | null>();
buildErrorMessage(file?: IFile): string {
const { errorMessage, ...config } = this.validationOptions;
@@ -105,6 +113,17 @@ export class FileTypeValidator extends FileValidator<
return `Validation failed (file buffer is not available; file type validation could not be performed; expected type is ${this.validationOptions.fileType})`;
}
const detectedMimeType = file && this.detectedMimeTypes.get(file);
if (detectedMimeType) {
return `Validation failed (current file type is ${detectedMimeType}, expected type is ${this.validationOptions.fileType})`;
}
if (
detectedMimeType === null &&
!this.validationOptions.fallbackToMimetype
) {
return `Validation failed (file type could not be detected from its content; expected type is ${this.validationOptions.fileType})`;
}
if (file?.mimetype) {
const baseMessage = `Validation failed (current file type is ${file.mimetype}, expected type is ${this.validationOptions.fileType})`;
@@ -115,7 +134,10 @@ export class FileTypeValidator extends FileValidator<
*
* This message clarifies that fallback logic was used, in case users rely on file signatures.
*/
if (this.validationOptions.fallbackToMimetype) {
if (
this.validationOptions.fallbackToMimetype &&
!this.validationOptions.skipMagicNumbersValidation
) {
return `${baseMessage} - magic number detection failed, used mimetype fallback`;
}
@@ -149,6 +171,7 @@ export class FileTypeValidator extends FileValidator<
try {
const { fileTypeFromBuffer } = await import('file-type');
const fileType = await fileTypeFromBuffer(file.buffer);
this.detectedMimeTypes.set(file, fileType?.mime ?? null);
if (fileType) {
if (this.validationOptions.overrideMimeType) {
@@ -168,6 +191,7 @@ export class FileTypeValidator extends FileValidator<
}
return false;
} catch (error) {
this.detectedMimeTypes.set(file, null);
const errorMessage =
error instanceof Error ? error.message : String(error);
@@ -467,6 +467,102 @@ describe('FileTypeValidator', () => {
);
});
it('should report the type detected from the content rather than the declared mimetype', async () => {
const fileTypeValidator = new FileTypeValidator({
fileType: 'image/jpeg',
});
const file = { mimetype: 'image/jpeg', buffer: pngBuffer } as IFile;
expect(await fileTypeValidator.isValid(file)).toBe(false);
expect(fileTypeValidator.buildErrorMessage(file)).toBe(
'Validation failed (current file type is image/png, expected type is image/jpeg)',
);
});
it('should not claim a mimetype fallback when the type was detected from the content', async () => {
const fileTypeValidator = new FileTypeValidator({
fileType: 'image/jpeg',
fallbackToMimetype: true,
});
const file = { mimetype: 'image/jpeg', buffer: pngBuffer } as IFile;
expect(await fileTypeValidator.isValid(file)).toBe(false);
expect(fileTypeValidator.buildErrorMessage(file)).toBe(
'Validation failed (current file type is image/png, expected type is image/jpeg)',
);
});
it('should report that the type could not be detected from the content', async () => {
const fileTypeValidator = new FileTypeValidator({
fileType: 'text/plain',
});
const file = {
mimetype: 'text/plain',
buffer: Buffer.from('hi'),
} as IFile;
expect(await fileTypeValidator.isValid(file)).toBe(false);
expect(fileTypeValidator.buildErrorMessage(file)).toBe(
'Validation failed (file type could not be detected from its content; expected type is text/plain)',
);
});
it('should mention the mimetype fallback when the type could not be detected from the content', async () => {
const fileTypeValidator = new FileTypeValidator({
fileType: 'text/plain',
fallbackToMimetype: true,
});
const file = {
mimetype: 'text/csv',
buffer: Buffer.from('hi'),
} as IFile;
expect(await fileTypeValidator.isValid(file)).toBe(false);
expect(fileTypeValidator.buildErrorMessage(file)).toBe(
'Validation failed (current file type is text/csv, expected type is text/plain) - magic number detection failed, used mimetype fallback',
);
});
it('should not claim a mimetype fallback when magic number validation is skipped', async () => {
const fileTypeValidator = new FileTypeValidator({
fileType: 'image/png',
skipMagicNumbersValidation: true,
fallbackToMimetype: true,
});
const file = { mimetype: 'image/jpeg', buffer: pngBuffer } as IFile;
expect(await fileTypeValidator.isValid(file)).toBe(false);
expect(fileTypeValidator.buildErrorMessage(file)).toBe(
'Validation failed (current file type is image/jpeg, expected type is image/png)',
);
});
it('should report the type detected for each file when the validator is shared', async () => {
const fileTypeValidator = new FileTypeValidator({
fileType: 'application/pdf',
});
const pngFile = {
mimetype: 'application/pdf',
buffer: pngBuffer,
} as IFile;
const jpegFile = {
mimetype: 'application/pdf',
buffer: jpegBuffer,
} as IFile;
await Promise.all([
fileTypeValidator.isValid(pngFile),
fileTypeValidator.isValid(jpegFile),
]);
expect(fileTypeValidator.buildErrorMessage(pngFile)).toBe(
'Validation failed (current file type is image/png, expected type is application/pdf)',
);
expect(fileTypeValidator.buildErrorMessage(jpegFile)).toBe(
'Validation failed (current file type is image/jpeg, expected type is application/pdf)',
);
});
it('should return false for text file with small buffer and correct mimetype but fail magic number validation', async () => {
const fileTypeValidator = new FileTypeValidator({
fileType: 'text/plain',