feat(formats): 添加安全的二进制数据读取器

Provides ByteReader, requireBytes, and precise FormatError subclasses (TruncatedDataError, InvalidFieldError) to enable safe decoding bounds checking.
This commit is contained in:
troytt 2026-09-14 13:16:54 +00:00
parent 7d8629cd73
commit a6ca337a10
2 changed files with 307 additions and 0 deletions

190
src/formats/reader.ts Normal file
View File

@ -0,0 +1,190 @@
export class FormatError extends Error {
public format: string;
public field: string;
public offset: number;
constructor(format: string, field: string, offset: number, message: string) {
super(message);
this.name = 'FormatError';
this.format = format;
this.field = field;
this.offset = offset;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class TruncatedDataError extends FormatError {
public needed: number;
public available: number;
constructor(
format: string,
field: string,
offset: number,
needed: number,
available: number,
bufferLength: number
) {
super(
format,
field,
offset,
`${format}: truncated reading ${field} at offset 0x${offset.toString(16).toUpperCase()}: need ${needed} bytes, only ${available} available (buffer length ${bufferLength})`
);
this.name = 'TruncatedDataError';
this.needed = needed;
this.available = available;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class InvalidFieldError extends FormatError {
public got: unknown;
public expected: unknown;
constructor(
format: string,
field: string,
offset: number,
got: unknown,
expected: unknown
) {
const gotStr = typeof got === 'number' ? `0x${got.toString(16).toUpperCase()}` : String(got);
const expStr = typeof expected === 'number' ? `0x${expected.toString(16).toUpperCase()}` : String(expected);
super(
format,
field,
offset,
`${format}: invalid field ${field} at offset 0x${offset.toString(16).toUpperCase()} (got ${gotStr}, expected ${expStr})`
);
this.name = 'InvalidFieldError';
this.got = got;
this.expected = expected;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export function requireBytes(
data: Uint8Array,
offset: number,
length: number,
format: string,
field: string
): void {
if (offset < 0 || offset + length > data.length) {
const available = data.length - offset;
throw new TruncatedDataError(
format,
field,
Math.max(0, offset),
length,
Math.max(0, available),
data.length
);
}
}
export class ByteReader {
private view: DataView;
private offset: number = 0;
private format: string;
private arr: Uint8Array;
constructor(data: Uint8Array, format: string) {
this.arr = data;
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
this.format = format;
}
get remaining(): number {
return this.arr.length - this.offset;
}
get eof(): boolean {
return this.offset >= this.arr.length;
}
get position(): number {
return this.offset;
}
private check(length: number, field: string): void {
if (this.offset + length > this.arr.length || this.offset < 0) {
throw new TruncatedDataError(
this.format,
field,
this.offset,
length,
Math.max(0, this.arr.length - this.offset),
this.arr.length
);
}
}
u8(field: string): number {
this.check(1, field);
const val = this.view.getUint8(this.offset);
this.offset += 1;
return val;
}
u16le(field: string): number {
this.check(2, field);
const val = this.view.getUint16(this.offset, true);
this.offset += 2;
return val;
}
u32le(field: string): number {
this.check(4, field);
const val = this.view.getUint32(this.offset, true);
this.offset += 4;
return val;
}
i16le(field: string): number {
this.check(2, field);
const val = this.view.getInt16(this.offset, true);
this.offset += 2;
return val;
}
i32le(field: string): number {
this.check(4, field);
const val = this.view.getInt32(this.offset, true);
this.offset += 4;
return val;
}
bytes(n: number, field: string): Uint8Array {
if (n < 0) {
throw new InvalidFieldError(this.format, field, this.offset, n, ">= 0");
}
this.check(n, field);
const val = this.arr.subarray(this.offset, this.offset + n);
this.offset += n;
return val;
}
skip(n: number, field: string): void {
if (n < 0) {
throw new InvalidFieldError(this.format, field, this.offset, n, ">= 0");
}
this.check(n, field);
this.offset += n;
}
seek(off: number, field: string): void {
if (off < 0 || off > this.arr.length) {
throw new TruncatedDataError(
this.format,
field,
off,
1,
0,
this.arr.length
);
}
this.offset = off;
}
}

117
tests/reader.test.ts Normal file
View File

@ -0,0 +1,117 @@
import { describe, expect, test } from 'vitest';
import {
FormatError,
TruncatedDataError,
InvalidFieldError,
ByteReader,
requireBytes
} from '../src/formats/reader';
describe('ByteReader', () => {
test('u8 reads correct value and advances', () => {
const data = new Uint8Array([0x01, 0x02]);
const reader = new ByteReader(data, 'test');
expect(reader.u8('f1')).toBe(0x01);
expect(reader.position).toBe(1);
expect(reader.remaining).toBe(1);
expect(reader.eof).toBe(false);
expect(reader.u8('f2')).toBe(0x02);
expect(reader.position).toBe(2);
expect(reader.remaining).toBe(0);
expect(reader.eof).toBe(true);
});
test('u8 throws TruncatedDataError at boundary', () => {
const data = new Uint8Array([0x01]);
const reader = new ByteReader(data, 'test');
reader.u8('f1');
try {
reader.u8('f2');
expect.fail('Should throw');
} catch (e: any) {
expect(e).toBeInstanceOf(TruncatedDataError);
expect(e).toBeInstanceOf(FormatError);
expect(e.format).toBe('test');
expect(e.field).toBe('f2');
expect(e.offset).toBe(1);
expect(e.needed).toBe(1);
expect(e.available).toBe(0);
expect(e.message).toBe('test: truncated reading f2 at offset 0x1: need 1 bytes, only 0 available (buffer length 1)');
}
});
test('u16le, u32le, i16le, i32le read correct values', () => {
const data = new Uint8Array([0xFE, 0xFF, 0x01, 0x00, 0x00, 0x80]);
// i16le of [0xFE, 0xFF] is -2
// u16le of [0xFE, 0xFF] is 65534
// i32le of [0x01, 0x00, 0x00, 0x80] is -2147483647
const r1 = new ByteReader(data, 'test');
expect(r1.i16le('f1')).toBe(-2);
expect(r1.i32le('f2')).toBe(-2147483647);
const r2 = new ByteReader(data, 'test');
expect(r2.u16le('f3')).toBe(65534);
r2.seek(2, 'f4');
expect(r2.u32le('f5')).toBe(0x80000001);
});
test('throws precise TruncatedDataError across types', () => {
const data = new Uint8Array([0x01, 0x02, 0x03]);
const r = new ByteReader(data, 'test');
r.skip(2, 'skip');
expect(() => r.u16le('u16')).toThrowError(
'test: truncated reading u16 at offset 0x2: need 2 bytes, only 1 available (buffer length 3)'
);
});
test('bytes() array methods', () => {
const data = new Uint8Array([0, 1, 2, 3, 4]);
const r = new ByteReader(data, 'test');
expect(r.bytes(2, 'b1')).toEqual(new Uint8Array([0, 1]));
expect(r.remaining).toBe(3);
r.skip(1, 'skip');
expect(r.position).toBe(3);
expect(() => r.bytes(4, 'b2')).toThrow(TruncatedDataError);
// test invalid lengths
expect(() => r.bytes(-1, 'b3')).toThrow(InvalidFieldError);
});
test('seek and skip logic', () => {
const data = new Uint8Array([0, 1, 2, 3]);
const r = new ByteReader(data, 'test');
r.seek(2, 'seek1');
expect(r.position).toBe(2);
r.seek(4, 'seek2');
expect(r.position).toBe(4);
expect(() => r.seek(5, 'seek3')).toThrow(TruncatedDataError);
});
});
describe('requireBytes', () => {
test('validates direct offsets', () => {
const data = new Uint8Array(10);
expect(() => requireBytes(data, 0, 10, 'fmt', 'f1')).not.toThrow();
expect(() => requireBytes(data, 5, 5, 'fmt', 'f2')).not.toThrow();
expect(() => requireBytes(data, 8, 3, 'fmt', 'f3')).toThrowError(
'fmt: truncated reading f3 at offset 0x8: need 3 bytes, only 2 available (buffer length 10)'
);
});
});
describe('InvalidFieldError', () => {
test('formats expected values', () => {
const err = new InvalidFieldError('fmt', 'sig', 16, 0x1234, 0xABCD);
expect(err.message).toBe('fmt: invalid field sig at offset 0x10 (got 0x1234, expected 0xABCD)');
const err2 = new InvalidFieldError('fmt', 'type', 0, 'bad', 'good');
expect(err2.message).toBe('fmt: invalid field type at offset 0x0 (got bad, expected good)');
});
});