diff --git a/package.json b/package.json index f2ea0f6..c0e9657 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "verify:audio": "tsx scripts/verify-audio.ts", "verify:tbl": "tsx scripts/verify-tbl.ts", + "verify:formats": "tsx scripts/verify-formats.ts", "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", @@ -18,7 +19,7 @@ "verify:implode": "tsx scripts/verify-implode.ts", "verify:collision-orientation": "tsx scripts/verify-collision-orientation.ts", "verify:renderer": "tsx scripts/verify-renderer-lifecycle.ts", - "verify:all": "tsx scripts/verify-combat.ts && tsx scripts/verify-items.ts && tsx scripts/verify-m4.ts && tsx scripts/verify-m5.ts && tsx scripts/verify-net.ts && tsx scripts/verify-collision-orientation.ts && tsx scripts/verify-tbl.ts && tsx scripts/verify-audio.ts", + "verify:all": "tsx scripts/verify-combat.ts && tsx scripts/verify-items.ts && tsx scripts/verify-m4.ts && tsx scripts/verify-m5.ts && tsx scripts/verify-net.ts && tsx scripts/verify-collision-orientation.ts && tsx scripts/verify-tbl.ts && tsx scripts/verify-audio.ts && tsx scripts/verify-formats.ts", "verify:acts": "tsx scripts/verify-acts.ts", "verify:generators": "tsx scripts/verify-generators.ts samples/d2", "build:game": "vite build --base=/diablo2/ --outDir dist-game", diff --git a/scripts/verify-formats.ts b/scripts/verify-formats.ts new file mode 100644 index 0000000..ea8a81a --- /dev/null +++ b/scripts/verify-formats.ts @@ -0,0 +1,55 @@ +import { fileURLToPath } from "url"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +import * as fs from 'fs'; +import * as path from 'path'; + +import { decodeDc6 } from '../src/formats/dc6'; +import { decodeDs1 } from '../src/formats/ds1'; +import { decodeDt1 } from '../src/formats/dt1'; +import { FormatError, TruncatedDataError, InvalidFieldError } from '../src/formats/reader'; + +console.log('Verifying formats against malformed data...'); + +let exitCode = 0; + +function runTest(name: string, fn: () => void) { + try { + fn(); + console.log(`[PASS] ${name}`); + } catch (e: any) { + if (e instanceof FormatError || e.message.includes('signature') || e.message.includes('implausible') || e.message.includes('truncated')) { + console.log(`[PASS] ${name} (threw expected error)`); + } else { + console.error(`[FAIL] ${name}: threw unexpected error type: ${e.name} - ${e.message}`); + exitCode = 1; + } + } +} + +const fixtureDir = path.join(__dirname, '../samples/fixtures'); + +if (fs.existsSync(path.join(fixtureDir, 'fixture.dc6'))) { + const dc6 = fs.readFileSync(path.join(fixtureDir, 'fixture.dc6')); + runTest('DC6 Truncated', () => decodeDc6(dc6.subarray(0, 10))); +} else { + console.log('[SKIP] DC6 fixture missing'); +} + +if (fs.existsSync(path.join(fixtureDir, 'fixture.ds1'))) { + const ds1 = fs.readFileSync(path.join(fixtureDir, 'fixture.ds1')); + runTest('DS1 Truncated', () => decodeDs1(ds1.subarray(0, 100))); + const badDs1 = new Uint8Array(ds1); + new DataView(badDs1.buffer).setInt32(4, 0xffff, true); + runTest('DS1 Absurd Geometry', () => decodeDs1(badDs1)); +} else { + console.log('[SKIP] DS1 fixture missing'); +} + +if (fs.existsSync(path.join(fixtureDir, 'fixture.dt1'))) { + const dt1 = fs.readFileSync(path.join(fixtureDir, 'fixture.dt1')); + runTest('DT1 Truncated', () => decodeDt1(dt1.subarray(0, 100))); +} else { + console.log('[SKIP] DT1 fixture missing'); +} + +process.exit(exitCode); diff --git a/tests/formats-malformed.test.ts b/tests/formats-malformed.test.ts new file mode 100644 index 0000000..844fce8 --- /dev/null +++ b/tests/formats-malformed.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { decodePal, decodeTrn, indicesToRgba } from '../src/formats/pal'; +import { decodePcx } from '../src/formats/pcx'; +import { decodeDc6 } from '../src/formats/dc6'; +import { decodeSpriteFile } from '../src/formats/cel'; +import { decodePl2 } from '../src/formats/pl2'; +import { decodeDs1 } from '../src/formats/ds1'; +import { decodeDt1 } from '../src/formats/dt1'; +import { decodeCof, cofLayerOrder } from '../src/formats/cof'; +import { decodeDcc } from '../src/formats/dcc'; + +import { TruncatedDataError, InvalidFieldError, FormatError } from '../src/formats/reader'; + +describe('Malformed binary formats fail loudly', () => { + + describe('pal', () => { + test('truncated', () => { + const data = new Uint8Array(700); + expect(() => decodePal(data)).toThrowError(); + // original was PaletteError, check it fails safely + }); + test('invalid trn', () => { + expect(() => decodeTrn(new Uint8Array(100))).toThrowError(); + }); + test('indicesToRgba bounds checks', () => { + const pal = { rgb: new Uint8Array(768), size: 256 }; + expect(() => indicesToRgba(new Uint8Array(10), new Uint8Array(5), pal)).toThrow(FormatError); + expect(() => indicesToRgba(new Uint8Array(10), new Uint8Array(10), { rgb: new Uint8Array(100), size: 256 })).toThrow(FormatError); + expect(() => indicesToRgba(new Uint8Array(10), new Uint8Array(10), pal, new Uint8Array(5))).toThrow(FormatError); + }); + }); + + describe('pcx', () => { + test('truncated header', () => { + expect(() => decodePcx(new Uint8Array(10))).toThrow(FormatError); + }); + test('invalid manufacturer', () => { + const data = new Uint8Array(1000); + data[0] = 0xff; // Invalid + expect(() => decodePcx(data)).toThrow(FormatError); + }); + test('geometry bounds checks', () => { + const data = new Uint8Array(1000); + data[0] = 0x0a; + data[3] = 8; + data[65] = 1; + const view = new DataView(data.buffer); + view.setUint16(4, 0, true); + view.setUint16(8, 0xffff, true); // Absurd width + // A well-formed palette trailer, so `geometry` is the ONLY broken + // invariant. Asserting the field name keeps this test honest: a coarse + // `toThrow(FormatError)` would still pass via the palette-marker guard + // even if the geometry guard were deleted outright. + data[data.length - 769] = 0x0c; + let caught: unknown; + try { + decodePcx(data); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(InvalidFieldError); + expect((caught as InvalidFieldError).field).toBe('geometry'); + }); + }); + + describe('dc6', () => { + const valid = fs.readFileSync(path.join(__dirname, '../samples/fixtures/fixture.dc6')); + + test('valid decodes successfully', () => { + const out = decodeDc6(valid); + expect(out.header.version).toBe(6); + }); + + test('truncated file', () => { + expect(() => decodeDc6(valid.subarray(0, 10))).toThrow(TruncatedDataError); + expect(() => decodeDc6(valid.subarray(0, 24))).toThrow(TruncatedDataError); + }); + + test('absurd directions', () => { + const bad = new Uint8Array(valid); + new DataView(bad.buffer).setInt32(0x10, 1000000, true); // Absurd direction + expect(() => decodeDc6(bad)).toThrow(FormatError); + }); + }); + + describe('pl2', () => { + test('truncated file', () => { + const expectedElements = 1024 + (32+16+1+256*3+256+256+111+1+1+1+14+256+1) * 256 + 39 + 13*256; + expect(() => decodePl2(new Uint8Array(expectedElements - 100))).toThrow(TruncatedDataError); + }); + }); + + describe('ds1', () => { + const valid = fs.readFileSync(path.join(__dirname, '../samples/fixtures/fixture.ds1')); + test('valid decodes successfully', () => { + const out = decodeDs1(valid); + expect(out.version).toBe(16); // Check known fixture version + }); + + test('truncated file', () => { + expect(() => decodeDs1(valid.subarray(0, 10))).toThrow(TruncatedDataError); + expect(() => decodeDs1(valid.subarray(0, valid.length - 100))).toThrow(TruncatedDataError); + }); + + test('absurd size', () => { + const bad = new Uint8Array(valid); + new DataView(bad.buffer).setInt32(4, 0xffff, true); // Absurd width + expect(() => decodeDs1(bad)).toThrow(FormatError); + }); + }); + + describe('dt1', () => { + const valid = fs.readFileSync(path.join(__dirname, '../samples/fixtures/fixture.dt1')); + test('valid decodes successfully', () => { + const out = decodeDt1(valid); + expect(out.versionMajor).toBe(7); + }); + + test('truncated file', () => { + expect(() => decodeDt1(valid.subarray(0, 20))).toThrow(TruncatedDataError); + expect(() => decodeDt1(valid.subarray(0, 50))).toThrow(TruncatedDataError); + }); + + test('absurd block offsets', () => { + const bad = new Uint8Array(valid); + new DataView(bad.buffer).setInt32(268 + 72 + 16, 0xffffff, true); new DataView(bad.buffer).setInt32(268 + 72 + 10, 100, true); // corrupt pointer + expect(() => decodeDt1(bad)).toThrow(FormatError); + }); + }); + + describe('cof', () => { + const hasD2Drop = fs.existsSync(path.join(__dirname, '../samples/d2')); + test.skipIf(!hasD2Drop)('valid decodes successfully on real asset', () => { + const validCof = fs.readFileSync(path.join(__dirname, '../samples/d2/data/global/chars/so/co/sowaxlbh.cof')); + const out = decodeCof(validCof); + expect(out.numberOfLayers).toBe(16); + }); + + test('truncated file', () => { + expect(() => decodeCof(new Uint8Array(10))).toThrow(TruncatedDataError); + }); + + test('absurd priority tables sizes', () => { + const header = new Uint8Array(28); // large enough for minimum headers + header[0] = 50; // layers + header[1] = 50; // frames + header[2] = 50; // directions + expect(() => decodeCof(header)).toThrow(TruncatedDataError); + }); + }); + + describe('dcc', () => { + test('invalid signature', () => { + const bad = new Uint8Array(100); + bad[0] = 0x11; + expect(() => decodeDcc(bad)).toThrow(/signature/); + }); + + test('absurd sizes', () => { + const bad = new Uint8Array(100); + bad[0] = 0x74; + bad[1] = 1; + bad[2] = 0xff; // directions = 255 + const dv = new DataView(bad.buffer); + dv.setInt32(3, 0xffffff, true); // Frames per dir + dv.setInt32(7, 1, true); // serialized = 1 + expect(() => decodeDcc(bad)).toThrow(/frames per direction/); + }); + }); +});