emmercm/igir

View on GitHub
src/types/files/fileSignature.ts

Summary

Maintainability
B
6 hrs
Test Coverage
import { Readable } from 'node:stream';

import { Memoize } from 'typescript-memoize';

type SignaturePiece = {
  offset?: number;
  value: Buffer;
};

export default class FileSignature {
  // @see https://en.wikipedia.org/wiki/List_of_file_signatures
  // @see https://www.garykessler.net/library/file_sigs.html
  // @see https://file-extension.net/seeker/
  // @see https://gbatemp.net/threads/help-with-rom-iso-console-identification.611378/
  private static readonly SIGNATURES: { [key: string]: FileSignature } = {
    // ********** GENERAL **********

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    elf: new FileSignature('.elf', [{ value: Buffer.from('\x7FELF') }]),

    // ********** ARCHIVES **********

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    '7z': new FileSignature('.7z', [{ value: Buffer.from('377ABCAF271C', 'hex') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    bz2: new FileSignature('.bz2', [{ value: Buffer.from('BZh') }]),

    // @see https://docs.fileformat.com/compression/gz/
    gz: new FileSignature('.gz', [{ value: Buffer.from('1F8B08', 'hex') }]), // deflate
    // .tar.gz has the same file signature

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    lz: new FileSignature('.lz', [{ value: Buffer.from('LZIP') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    lz4: new FileSignature('.lz4', [{ value: Buffer.from('04224D18', 'hex') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    lzh: new FileSignature('.lzh', [
      { value: Buffer.from('-lh') },
      { offset: 4, value: Buffer.from('-') },
    ]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    oar: new FileSignature('.oar', [{ value: Buffer.from('OAR') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    rar1: new FileSignature('.rar', [{ value: Buffer.from('Rar!\x1A\x07\x00') }]), // v1.50+
    rar5: new FileSignature('.rar', [{ value: Buffer.from('Rar!\x1A\x07\x01\x00') }]), // v5.00+

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    rs: new FileSignature('.rs', [{ value: Buffer.from('RSVKDATA') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    tar1: new FileSignature('.tar', [{ offset: 257, value: Buffer.from('ustar\x0000') }]),
    tar2: new FileSignature('.tar', [{ offset: 257, value: Buffer.from('ustar\x20\x20\x00') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    xar: new FileSignature('.xar', [{ value: Buffer.from('xar!') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    xz: new FileSignature('.xz', [{ value: Buffer.from('\xFD7zXZ\x00') }]),
    // .tar.xz has the same file signature

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    z_lzw: new FileSignature('.z', [{ value: Buffer.from('1F9D', 'hex') }]), // LZW compression
    z_lzh: new FileSignature('.z', [{ value: Buffer.from('1FA0', 'hex') }]), // LZH compression
    // .tar.z has the same file signature

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    zip: new FileSignature('.zip', [{ value: Buffer.from('PK\x03\x04') }]),
    zip_empty: new FileSignature('.zip', [{ value: Buffer.from('PK\x05\x06') }]), // empty archive
    // .zipx has the same file signature?

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    zst: new FileSignature('.zst', [{ value: Buffer.from('28B52FFD', 'hex') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    z01: new FileSignature('.z01', [{ value: Buffer.from('PK\x07\x08') }]),

    // ********** ROMs - GENERAL **********

    chd: new FileSignature('.chd', [{ value: Buffer.from('MComprHD') }]),

    // @see https://docs.fileformat.com/disc-and-media/cso/
    cso: new FileSignature('.cso', [{ value: Buffer.from('CISO') }]),

    dax: new FileSignature('.dax', [{ value: Buffer.from('DAX') }]),

    // @see https://en.wikipedia.org/wiki/List_of_file_signatures
    isz: new FileSignature('.isz', [{ value: Buffer.from('IsZ!') }]),

    // @see https://docs.fileformat.com/disc-and-media/cso/
    zso: new FileSignature('.zso', [{ value: Buffer.from('ZISO') }]),

    // ********** ROMs - SPECIFIC **********

    // Atari - 7800
    a78: new FileSignature('.a78', [{ offset: 1, value: Buffer.from('ATARI7800') }]),

    // Atari - Lynx
    lnx: new FileSignature('.lnx', [{ value: Buffer.from('LYNX') }]),

    // Nintendo - Nintendo 3DS
    '3dsx': new FileSignature('.3dsx', [{ value: Buffer.from('3DSX') }]),

    // Nintendo - Nintendo 64
    // @see http://n64dev.org/romformats.html
    n64: new FileSignature('.n64', [{ value: Buffer.from('40123780', 'hex') }]), // little endian
    v64: new FileSignature('.v64', [{ value: Buffer.from('37804012', 'hex') }]), // byte-swapped
    z64: new FileSignature('.z64', [{ value: Buffer.from('80371240', 'hex') }]), // native

    // Nintendo - Nintendo 64 Disk Drive
    ndd: new FileSignature('.ndd', [{ value: Buffer.from('E848D31610', 'hex') }]),

    // Nintendo - Famicom Disk System
    fds_hvc: new FileSignature('.fds', [{ value: Buffer.from('\x01*NINTENDO-HVC*') }]),
    fds: new FileSignature('.fds', [{ value: Buffer.from('FDS') }]),

    // Nintendo - Game & Watch
    gw: new FileSignature('.bin', [{ value: Buffer.from('main.bs') }]),

    // Nintendo - GameCube
    // @see https://github.com/dolphin-emu/dolphin/blob/1f5e100a0e6dd4f9ab3784fd6373d452054d08bf/Source/Core/DiscIO/CompressedBlob.h#L25 (reversed)
    gcz: new FileSignature('.gcz', [{ value: Buffer.from('01C00BB1', 'hex') }]),
    // @see https://wiki.gbatemp.net/wiki/NKit/NKitFormat
    nkit_iso: new FileSignature('.nkit.iso', [{ offset: 0x2_00, value: Buffer.from('NKIT') }]),
    // @see https://github.com/dolphin-emu/dolphin/blob/master/docs/WiaAndRvz.md
    rvz: new FileSignature('.rvz', [{ value: Buffer.from('RVZ\x01') }]), // "RVZ\x01"
    wia: new FileSignature('.wia', [{ value: Buffer.from('WIA\x01') }]), // "WIA\x01"

    // Nintendo - Game Boy
    // @see https://gbdev.io/pandocs/The_Cartridge_Header.html
    gb: new FileSignature('.gb', [
      {
        offset: 0x01_04,
        value: Buffer.from(
          'CEED6666CC0D000B03730083000C000D0008111F8889000EDCCC6EE6DDDDD999BBBB67636E0EECCCDDDC999FBBB9333E',
          'hex',
        ),
      }, // logo
      { offset: 0x01_43, value: Buffer.from('00', 'hex') }, // non-color
    ]),

    // Nintendo - Game Boy Advance
    // @see http://problemkaputt.de/gbatek.htm#gbacartridges
    gba: new FileSignature('.gba', [
      {
        offset: 0x04,
        value: Buffer.from(
          '24FFAE51699AA2213D84820A84E409AD11248B98C0817F21A352BE199309CE2010464A4AF82731EC58C7E83382E3CEBF85F4DF94CE4B09C194568AC01372A7FC9F844D73A3CA9A615897A327FC039876231DC7610304AE56BF38840040A70EFDFF52FE036F9530F197FBC08560D68025A963BE03014E38E2F9A234FFBB3E0344780090CB88113A9465C07C6387F03CAFD625E48B380AAC7221D4F807',
          'hex',
        ),
      },
    ]), // logo

    // Nintendo - Game Boy Color
    // @see https://gbdev.io/pandocs/The_Cartridge_Header.html
    gb_dx: new FileSignature('.gbc', [
      {
        offset: 0x01_04,
        value: Buffer.from(
          'CEED6666CC0D000B03730083000C000D0008111F8889000EDCCC6EE6DDDDD999BBBB67636E0EECCCDDDC999FBBB9333E',
          'hex',
        ),
      }, // logo
      { offset: 0x01_43, value: Buffer.from('80', 'hex') }, // backwards compatible
    ]),
    gbc: new FileSignature('.gbc', [
      {
        offset: 0x01_04,
        value: Buffer.from(
          'CEED6666CC0D000B03730083000C000D0008111F8889000EDCCC6EE6DDDDD999BBBB67636E0EECCCDDDC999FBBB9333E',
          'hex',
        ),
      }, // logo
      { offset: 0x01_43, value: Buffer.from('C0', 'hex') }, // color only
    ]),

    // Nintendo - Nintendo DS (Decrypted)
    // @see http://dsibrew.org/wiki/DSi_cartridge_header
    nds: new FileSignature('.nds', [
      {
        offset: 0xc0,
        value: Buffer.from(
          '24FFAE51699AA2213D84820A84E409AD11248B98C0817F21A352BE199309CE2010464A4AF82731EC58C7E83382E3CEBF85F4DF94CE4B09C194568AC01372A7FC9F844D73A3CA9A615897A327FC039876231DC7610304AE56BF38840040A70EFDFF52FE036F9530F197FBC08560D68025A963BE03014E38E2F9A234FFBB3E0344780090CB88113A9465C07C6387F03CAFD625E48B380AAC7221D4F807',
        ),
      }, // logo
      { offset: 0x1_5c, value: Buffer.from('56CF', 'hex') }, // logo checksum
    ]),

    // Nintendo - Nintendo Entertainment System
    nes: new FileSignature('.nes', [{ value: Buffer.from('NES') }]),

    // Nintendo - Super Nintendo Entertainment System
    // @see https://snes.nesdev.org/wiki/ROM_header
    // @see https://en.wikibooks.org/wiki/Super_NES_Programming/SNES_memory_map
    // TODO(cemmer): add checks from LoROM, HiROM, etc.
    smc: new FileSignature('.smc', [{ offset: 3, value: Buffer.from('00'.repeat(509), 'hex') }]),
    // @see https://file-extension.net/seeker/file_extension_smc
    // @see https://wiki.superfamicom.org/game-doctor
    smc_gd3_1: new FileSignature('.smc', [{ value: Buffer.from('\x00\x01ME DOCTOR SF 3') }]), // Game Doctor SF3?
    smc_gd3_2: new FileSignature('.smc', [{ value: Buffer.from('GAME DOCTOR SF 3') }]), // Game Doctor SF3/SF6/SF7

    // Nintendo - Wii
    // @see https://wit.wiimm.de/info/wdf.html
    wdf: new FileSignature('.wdf', [{ value: Buffer.from('WII\x01DISC') }]),

    // Nintendo - Wii U
    // @see https://github.com/cemu-project/Cemu/blob/7522c8470ee27d50a68ba662ae721b69018f3a8f/src/Cafe/Filesystem/WUD/wud.h#L25
    wux: new FileSignature('.wux', [{ value: Buffer.from('WUX0\x2E\xD0\x99\x10') }]),

    // Sega - 32X
    // @see https://github.com/jcfieldsdev/genesis-rom-utility/blob/31826bca66c8c6c467c37c1b711943eb5464e7e8/genesis_rom.chm
    // @see https://plutiedev.com/rom-header
    '32x': new FileSignature('.32x', [{ offset: 0x1_00, value: Buffer.from('SEGA 32X') }]),

    // Sega - Game Gear
    // @see https://gbatemp.net/threads/help-with-rom-iso-console-identification.611378/
    gg: new FileSignature('.gg', [{ offset: 0x7f_f0, value: Buffer.from('TMR SEGA') }]),

    // Sega - Mega Drive / Genesis
    // @see https://github.com/jcfieldsdev/genesis-rom-utility/blob/31826bca66c8c6c467c37c1b711943eb5464e7e8/genesis_rom.chm
    // @see https://plutiedev.com/rom-header
    md_1: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA            ') }]),
    md_2: new FileSignature('.md', [
      { offset: 0x1_00, value: Buffer.from('SEGA IS A REGISTERED') },
    ]),
    md_3: new FileSignature('.md', [
      { offset: 0x1_00, value: Buffer.from('SEGA IS A TRADEMARK ') },
    ]),
    md_4: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA GENESIS') }]),
    md_5: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA GENESIS') }]),
    md_6: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA_GENESIS') }]),
    md_7: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA MEGADRIVE') }]),
    md_8: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA MEGA DRIVE') }]),
    md_9: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA MEGA DRIVE') }]),
    md_10: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA_MEGA_DRIVE') }]),
    md_11: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA_MEGA_DRIVE') }]),
    md_12: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGASEGASEGA') }]),
    // @see https://www.romhacking.net/forum/index.php?topic=32880.msg415017#msg415017
    smd_1: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAGNSS  ') }]),
    smd_2: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMG RV') }]),
    smd_3: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMG_RV') }]),
    smd_4: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMGDIE') }]),
    smd_5: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('SG EEI  ') }]),

    // Sega - PICO
    // @see https://github.com/jcfieldsdev/genesis-rom-utility/blob/31826bca66c8c6c467c37c1b711943eb5464e7e8/genesis_rom.chm
    // @see https://plutiedev.com/rom-header
    pico: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA PICO') }]),

    // Sony - PlayStation Portable
    // @see https://www.psdevwiki.com/ps3/Eboot.PBP
    pbp: new FileSignature('.pbp', [{ value: Buffer.from('\x00PBP\x00\x00\x01\x00') }]),
  };

  private static readonly SIGNATURES_SORTED = Object.values(FileSignature.SIGNATURES).sort(
    (a, b) => {
      // 1. Prefer files that check multiple signatures
      const sigsCountDiff = b.fileSignatures.length - a.fileSignatures.length;
      if (sigsCountDiff !== 0) {
        return sigsCountDiff;
      }

      // 2. Prefer signatures of longer length
      return (
        b.fileSignatures.reduce((sum, sig) => sum + sig.value.length, 0) -
        a.fileSignatures.reduce((sum, sig) => sum + sig.value.length, 0)
      );
    },
  );

  private static readonly MAX_HEADER_LENGTH_BYTES = Object.values(FileSignature.SIGNATURES)
    .flatMap((romSignature) => romSignature.fileSignatures)
    .reduce(
      (max, fileSignature) =>
        Math.max(max, (fileSignature.offset ?? 0) + fileSignature.value.length),
      0,
    );

  private readonly extension: string;

  private readonly fileSignatures: SignaturePiece[];

  constructor(extension: string, fileSignatures: SignaturePiece[]) {
    this.extension = extension;
    this.fileSignatures = fileSignatures;
  }

  static getKnownSignatureCount(): number {
    return this.SIGNATURES_SORTED.length;
  }

  private static async readHeaderBuffer(
    stream: Readable,
    start: number,
    end: number,
  ): Promise<Buffer> {
    return new Promise((resolve, reject) => {
      stream.resume();

      const chunks: Buffer[] = [];
      const resolveHeader: () => void = () => {
        const header = Buffer.concat(chunks).subarray(start, end);
        resolve(header);
      };

      stream.on('data', (chunk) => {
        chunks.push(Buffer.from(chunk));

        // Stop reading when we get enough data, trigger a 'close' event
        if (chunks.reduce((sum, buff) => sum + buff.length, 0) >= end) {
          resolveHeader();
          // WARN(cemmer): whatever created the stream may need to drain it!
        }
      });

      stream.on('end', resolveHeader);
      stream.on('error', reject);
    });
  }

  static signatureFromName(name: string): FileSignature | undefined {
    return this.SIGNATURES[name];
  }

  static async signatureFromFileStream(stream: Readable): Promise<FileSignature | undefined> {
    const fileHeader = await FileSignature.readHeaderBuffer(
      stream,
      0,
      this.MAX_HEADER_LENGTH_BYTES,
    );

    for (const romSignature of this.SIGNATURES_SORTED) {
      const signatureMatch = romSignature.fileSignatures.every((fileSignature) => {
        const signatureValue = fileHeader.subarray(
          fileSignature.offset ?? 0,
          (fileSignature.offset ?? 0) + fileSignature.value.length,
        );
        return signatureValue.equals(fileSignature.value);
      });
      if (signatureMatch) {
        return romSignature;
      }
    }

    return undefined;
  }

  @Memoize()
  getName(): string {
    return Object.keys(FileSignature.SIGNATURES).find(
      (name) => FileSignature.SIGNATURES[name] === this,
    ) as string;
  }

  getExtension(): string {
    return this.extension;
  }
}