451 lines
18 KiB
JavaScript
451 lines
18 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs/promises';
|
|
import { detectInputMime, outputMimeType, resolveInputFormat } from '../src/mime.js';
|
|
|
|
describe('detectInputMime', () => {
|
|
it('detects PNG from magic bytes regardless of file extension', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');
|
|
|
|
const result = await detectInputMime(fixturePath);
|
|
|
|
expect(result).toEqual({ ext: 'png', mime: 'image/png' });
|
|
});
|
|
});
|
|
|
|
describe('outputMimeType', () => {
|
|
it('returns the MIME type for a known target format', () => {
|
|
expect(outputMimeType('pdf')).toBe('application/pdf');
|
|
expect(outputMimeType('png')).toBe('image/png');
|
|
expect(outputMimeType('docx')).toBe(
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
);
|
|
expect(outputMimeType('md')).toBe('text/markdown');
|
|
});
|
|
|
|
it('throws for an unknown target format', () => {
|
|
expect(() => outputMimeType('made-up-format')).toThrowError(/made-up-format/);
|
|
});
|
|
});
|
|
|
|
describe('outputMimeType — ico', () => {
|
|
it('returns image/x-icon for ico', () => {
|
|
expect(outputMimeType('ico')).toBe('image/x-icon');
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat', () => {
|
|
it('accepts a PNG file declared as png', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'png');
|
|
|
|
expect(result).toEqual({ mime: 'image/png', valid: true });
|
|
});
|
|
|
|
it('rejects a PNG file declared as a different format', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'pdf');
|
|
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it('treats jpg and jpeg as equivalent declared formats', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');
|
|
const result = await resolveInputFormat(fixturePath, 'png');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('trusts the declared format for undetectable txt files', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.txt');
|
|
await fs.writeFile(fixturePath, 'plain text, no magic bytes');
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'txt');
|
|
|
|
expect(result).toEqual({ mime: 'text/plain', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('trusts the declared format for undetectable md files', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.md');
|
|
await fs.writeFile(fixturePath, '# plain markdown, no magic bytes');
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'md');
|
|
|
|
expect(result).toEqual({ mime: 'text/markdown', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('rejects an undetectable file declared as a binary format', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-fake.png');
|
|
await fs.writeFile(fixturePath, 'this is not really a PNG');
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'png');
|
|
|
|
expect(result.valid).toBe(false);
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — heic/heif alias', () => {
|
|
it('accepts a HEIC-family file declared as heic', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.heic');
|
|
const result = await resolveInputFormat(fixturePath, 'heic');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('accepts the same HEIC-family content declared as heif', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.heif');
|
|
const result = await resolveInputFormat(fixturePath, 'heif');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('outputMimeType — fonts', () => {
|
|
it('returns the correct MIME type for each font target format', () => {
|
|
expect(outputMimeType('ttf')).toBe('font/ttf');
|
|
expect(outputMimeType('otf')).toBe('font/otf');
|
|
expect(outputMimeType('woff')).toBe('font/woff');
|
|
});
|
|
});
|
|
|
|
describe('outputMimeType — ebooks', () => {
|
|
it('returns the correct MIME type for each ebook target format', () => {
|
|
expect(outputMimeType('epub')).toBe('application/epub+zip');
|
|
expect(outputMimeType('fb2')).toBe('application/x-fictionbook+xml');
|
|
expect(outputMimeType('mobi')).toBe('application/x-mobipocket-ebook');
|
|
expect(outputMimeType('azw3')).toBe('application/vnd.amazon.ebook');
|
|
expect(outputMimeType('pdb')).toBe('application/vnd.palm');
|
|
expect(outputMimeType('lrf')).toBe('application/octet-stream');
|
|
expect(outputMimeType('rb')).toBe('application/octet-stream');
|
|
expect(outputMimeType('snb')).toBe('application/octet-stream');
|
|
expect(outputMimeType('tcr')).toBe('application/octet-stream');
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — azw3/mobi collision', () => {
|
|
function buildMobiFamilyBuffer() {
|
|
// PDB header: 8-byte "BOOKMOBI" type+creator magic at offset 60, shared by both
|
|
// real .mobi and real .azw3 files (confirmed in file-type's source).
|
|
const buffer = Buffer.alloc(68);
|
|
buffer.write('BOOKMOBI', 60, 'ascii');
|
|
return buffer;
|
|
}
|
|
|
|
it('confirms file-type reports mobi-family content as ext "mobi" regardless of which of the two formats it is (documents the collision this fix works around)', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin');
|
|
await fs.writeFile(fixturePath, buildMobiFamilyBuffer());
|
|
|
|
const detected = await detectInputMime(fixturePath);
|
|
expect(detected).toEqual({ ext: 'mobi', mime: 'application/x-mobipocket-ebook' });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a real mobi-family file declared as mobi', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin');
|
|
await fs.writeFile(fixturePath, buildMobiFamilyBuffer());
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'mobi');
|
|
expect(result).toEqual({ mime: 'application/x-mobipocket-ebook', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a real mobi-family file declared as azw3 despite file-type reporting it as mobi', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin');
|
|
await fs.writeFile(fixturePath, buildMobiFamilyBuffer());
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'azw3');
|
|
expect(result).toEqual({ mime: 'application/x-mobipocket-ebook', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — undetectable ebook formats', () => {
|
|
const undetectableFormats = {
|
|
lrf: 'application/octet-stream',
|
|
pdb: 'application/vnd.palm',
|
|
rb: 'application/octet-stream',
|
|
snb: 'application/octet-stream',
|
|
tcr: 'application/octet-stream',
|
|
};
|
|
|
|
it.each(Object.entries(undetectableFormats))(
|
|
'trusts the declared format for undetectable %s files',
|
|
async (declaredFormat, expectedMime) => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', `sample.${declaredFormat}`);
|
|
await fs.writeFile(fixturePath, 'arbitrary bytes with no recognizable magic number');
|
|
|
|
const result = await resolveInputFormat(fixturePath, declaredFormat);
|
|
|
|
expect(result).toEqual({ mime: expectedMime, valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('resolveInputFormat — fb2', () => {
|
|
it('accepts real fb2 content (file-type sniffs its XML declaration as generic "xml", not "fb2")', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-real.fb2');
|
|
await fs.writeFile(
|
|
fixturePath,
|
|
'<?xml version="1.0" encoding="utf-8"?><FictionBook><body><p>hello</p></body></FictionBook>'
|
|
);
|
|
|
|
const detected = await detectInputMime(fixturePath);
|
|
expect(detected).toEqual({ ext: 'xml', mime: 'application/xml' });
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'fb2');
|
|
expect(result).toEqual({ mime: 'application/xml', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('falls back to trusting the declared format for fb2 content with no recognizable XML declaration', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-no-prolog.fb2');
|
|
await fs.writeFile(fixturePath, 'arbitrary bytes with no recognizable magic number');
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'fb2');
|
|
expect(result).toEqual({ mime: 'application/x-fictionbook+xml', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — dfont', () => {
|
|
it('confirms file-type alone misidentifies the dfont fixture as ico (documents the bug this fix works around)', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.dfont');
|
|
const detected = await detectInputMime(fixturePath);
|
|
expect(detected.ext).toBe('ico');
|
|
});
|
|
|
|
it('accepts a real dfont file despite that misidentification', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.dfont');
|
|
const result = await resolveInputFormat(fixturePath, 'dfont');
|
|
expect(result).toEqual({ mime: 'application/x-dfont', valid: true });
|
|
});
|
|
|
|
it('rejects content starting with the colliding magic bytes that is not a real dfont', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-fake.dfont');
|
|
await fs.writeFile(fixturePath, Buffer.from([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10]));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'dfont');
|
|
expect(result).toEqual({ mime: null, valid: false });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
});
|
|
|
|
describe('outputMimeType — archives', () => {
|
|
it('returns the correct MIME type for each archive target format', () => {
|
|
expect(outputMimeType('zip')).toBe('application/zip');
|
|
expect(outputMimeType('tar')).toBe('application/x-tar');
|
|
expect(outputMimeType('tar.gz')).toBe('application/gzip');
|
|
expect(outputMimeType('tar.bz2')).toBe('application/x-bzip2');
|
|
expect(outputMimeType('7z')).toBe('application/x-7z-compressed');
|
|
expect(outputMimeType('tar.7z')).toBe('application/x-7z-compressed');
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — compound archive extensions', () => {
|
|
it('accepts a real gzip file declared as tar.gz (file-type sniffs the outer gzip layer only)', async () => {
|
|
const zlib = await import('node:zlib');
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.tar.gz');
|
|
await fs.writeFile(fixturePath, zlib.gzipSync(Buffer.from('irrelevant payload for this check')));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'tar.gz');
|
|
expect(result.valid).toBe(true);
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a real 7z file declared as tar.7z (file-type sniffs the outer 7z layer only)', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.tar.7z');
|
|
// Minimal valid 7z signature header (6-byte magic + 2-byte version), enough for file-type to sniff `ext: '7z'`.
|
|
await fs.writeFile(fixturePath, Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x04]));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'tar.7z');
|
|
expect(result.valid).toBe(true);
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
});
|
|
|
|
describe('outputMimeType — audio', () => {
|
|
it('returns the correct MIME type for each audio target format', () => {
|
|
expect(outputMimeType('mp3')).toBe('audio/mpeg');
|
|
expect(outputMimeType('wav')).toBe('audio/wav');
|
|
expect(outputMimeType('ogg')).toBe('audio/ogg');
|
|
expect(outputMimeType('flac')).toBe('audio/flac');
|
|
expect(outputMimeType('aac')).toBe('audio/aac');
|
|
expect(outputMimeType('m4a')).toBe('audio/x-m4a');
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — audio formats', () => {
|
|
it('accepts a WAV file (RIFF....WAVE) declared as wav', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.wav');
|
|
await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'wav');
|
|
expect(result).toEqual({ mime: 'audio/wav', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a FLAC file (fLaC magic) declared as flac', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.flac');
|
|
await fs.writeFile(fixturePath, Buffer.from('fLaC'));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'flac');
|
|
expect(result).toEqual({ mime: 'audio/flac', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts an MP3 file (MPEG layer 3 frame sync) declared as mp3', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.mp3');
|
|
// Frame sync 0xFF 0xFB: byte1 & 0xE0 == 0xE0 (sync), byte1 & 0x06 == 0x02 (layer 3, not ADTS).
|
|
await fs.writeFile(fixturePath, Buffer.from([0xff, 0xfb, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00]));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'mp3');
|
|
expect(result).toEqual({ mime: 'audio/mpeg', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts an AAC/ADTS file declared as aac', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.aac');
|
|
// Frame sync 0xFF 0xF1: byte1 & 0xE0 == 0xE0 (sync), byte1 & 0x16 == 0x10 (ADTS, not layer 3).
|
|
await fs.writeFile(fixturePath, Buffer.from([0xff, 0xf1, 0x4c, 0x80, 0x01, 0x3f, 0xfc, 0x00]));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'aac');
|
|
expect(result).toEqual({ mime: 'audio/aac', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts an OGG/Vorbis file declared as ogg', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.ogg');
|
|
// 'OggS' + 24 ignored bytes (file-type's ignore(28) counts from the 'OggS' magic itself,
|
|
// confirmed empirically against the installed file-type package) + packet-type field '\x01vorbis'.
|
|
const body = Buffer.concat([
|
|
Buffer.from('OggS'),
|
|
Buffer.alloc(24),
|
|
Buffer.concat([Buffer.from([0x01]), Buffer.from('vorbis'), Buffer.alloc(1)]),
|
|
]);
|
|
await fs.writeFile(fixturePath, body);
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'ogg');
|
|
expect(result).toEqual({ mime: 'audio/ogg', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts an M4A file (ISO-BMFF ftyp box, M4A brand) declared as m4a', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.m4a');
|
|
// 4-byte box size (unchecked) + 'ftyp' + major brand 'M4A ' (space-padded, 4 bytes).
|
|
const body = Buffer.concat([Buffer.alloc(4), Buffer.from('ftyp'), Buffer.from('M4A ')]);
|
|
await fs.writeFile(fixturePath, body);
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'm4a');
|
|
expect(result).toEqual({ mime: 'audio/x-m4a', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
});
|
|
|
|
describe('outputMimeType — video', () => {
|
|
it('returns the correct MIME type for each video target format', () => {
|
|
expect(outputMimeType('mp4')).toBe('video/mp4');
|
|
expect(outputMimeType('webm')).toBe('video/webm');
|
|
expect(outputMimeType('mov')).toBe('video/quicktime');
|
|
expect(outputMimeType('avi')).toBe('video/x-msvideo');
|
|
expect(outputMimeType('mkv')).toBe('video/x-matroska');
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — video magic bytes', () => {
|
|
it('accepts a minimal mp4 (ftyp box) declared as mp4', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.mp4');
|
|
await fs.writeFile(
|
|
fixturePath,
|
|
Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')])
|
|
);
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'mp4');
|
|
expect(result).toEqual({ mime: 'video/mp4', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a minimal mov (moov atom) declared as mov', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.mov');
|
|
await fs.writeFile(fixturePath, Buffer.concat([Buffer.from([0, 0, 0, 0x08]), Buffer.from('moov')]));
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'mov');
|
|
expect(result).toEqual({ mime: 'video/quicktime', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a minimal avi (RIFF/AVI) declared as avi', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.avi');
|
|
await fs.writeFile(
|
|
fixturePath,
|
|
Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('AVI ')])
|
|
);
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'avi');
|
|
expect(result).toEqual({ mime: 'video/vnd.avi', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a minimal webm (EBML DocType webm) declared as webm', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.webm');
|
|
await fs.writeFile(
|
|
fixturePath,
|
|
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x84]), Buffer.from('webm')])
|
|
);
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'webm');
|
|
expect(result).toEqual({ mime: 'video/webm', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('accepts a minimal mkv (EBML DocType matroska) declared as mkv', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.mkv');
|
|
await fs.writeFile(
|
|
fixturePath,
|
|
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x88]), Buffer.from('matroska')])
|
|
);
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'mkv');
|
|
expect(result).toEqual({ mime: 'video/matroska', valid: true });
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
|
|
it('rejects a webm file declared as mkv', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video-mismatch.webm');
|
|
await fs.writeFile(
|
|
fixturePath,
|
|
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x84]), Buffer.from('webm')])
|
|
);
|
|
|
|
const result = await resolveInputFormat(fixturePath, 'mkv');
|
|
expect(result.valid).toBe(false);
|
|
|
|
await fs.unlink(fixturePath);
|
|
});
|
|
});
|