diff --git a/src/formats/reader.ts b/src/formats/reader.ts new file mode 100644 index 0000000..296d453 --- /dev/null +++ b/src/formats/reader.ts @@ -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; + } +} diff --git a/tests/reader.test.ts b/tests/reader.test.ts new file mode 100644 index 0000000..3a26160 --- /dev/null +++ b/tests/reader.test.ts @@ -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)'); + }); +});