feat(formats): 添加安全的二进制数据读取器
Provides ByteReader, requireBytes, and precise FormatError subclasses (TruncatedDataError, InvalidFieldError) to enable safe decoding bounds checking.
This commit is contained in:
parent
7d8629cd73
commit
a6ca337a10
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue