test(formats): 增加针对损坏资产的覆盖率和回归测试 (fixes #11)
Implements regression limits testing bounds limits, invalid header mappings and stream truncations for binary formats, verifying the decoder engine halts cleanly without exception masking or propagation leaks. Ensures zero false positives and safely covers buffer boundary rules.
This commit is contained in:
parent
53f52ba61e
commit
f52edd85f9
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue