diff --git a/eslint.config.js b/eslint.config.js index c6df6b2..79d8908 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,5 +15,12 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + "@typescript-eslint/ban-ts-comment": [ + "error", { + "ts-ignore": "allow-with-description" + } + ], + } }, ]) diff --git a/package.json b/package.json index 66fd148..ed9da2f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "@hamradio/packet", + "type": "module", "version": "1.0.0", "description": "Low level packet parsing library (for radio protocols)", "keywords": [ diff --git a/src/index.ts b/src/index.ts index 634a240..b2bcd20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -770,3 +770,25 @@ export class Writer { } export { FieldType } from './types'; +export { + isBytes, + assertBytes, + bytesToHex, + hexToBytes, + bytesToBase64, + base64ToBytes, + U8, + U16, + U32, + I8, + I16, + I32, + F32, + F64, + type Encoding, + type HexEncoding, + type Base64Encoding, + type Base64RawEncoding, + type Base64URLEncoding, + type Base64RawURLEncoding, +} from './utils'; diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..ec9defe --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { + U8, + U16, + U32, + I8, + I16, + I32, + F32, + F64, + isBytes, + assertBytes, + bytesToHex, + hexToBytes, + bytesToBase64, + base64ToBytes, + decodeBytes, + encodeBytes, +} from './utils'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +describe('utils', () => { + const staticHexVectors = [ + { bytes: Uint8Array.from([]), hex: '' }, + { bytes: Uint8Array.from([0xbe]), hex: 'be' }, + { bytes: Uint8Array.from([0xca, 0xfe]), hex: 'cafe' }, + { bytes: Uint8Array.from(new Array(1024).fill(0x69)), hex: '69'.repeat(1024) }, + ]; + + const buf = new ArrayBuffer(8); + const v8 = new Uint8Array(buf); + for (let i = 0; i < v8.length; i++) v8[i] = i + 1; + + it('U8 from ArrayBuffer', () => { + const a8 = U8(buf); + expect(a8).toBeInstanceOf(Uint8Array); + expect(Array.from(a8)).toEqual([1,2,3,4,5,6,7,8]); + }); + + it('U8 from Uint8Array view', () => { + const sub = new Uint8Array(buf, 2, 4); + const s8 = U8(sub); + expect(Array.from(s8)).toEqual([3,4,5,6]); + }); + + it('U16 from ArrayBuffer', () => { + const a16 = U16(buf); + expect(a16).toBeInstanceOf(Uint16Array); + expect(a16.length).toBe(Math.floor(buf.byteLength / 2)); + }); + + it('U16 from Uint8Array view', () => { + const sub = new Uint8Array(buf, 2, 4); + const s16 = U16(sub); + expect(s16.length).toBe(Math.floor(sub.byteLength / 2)); + }); + + it('U32 from ArrayBuffer', () => { + const a32 = U32(buf); + expect(a32).toBeInstanceOf(Uint32Array); + expect(a32.length).toBe(Math.floor(buf.byteLength / 4)); + }); + + it('U32 from Uint8Array view', () => { + const sub = new Uint8Array(buf, 4, 4); + const s32 = U32(sub); + expect(s32.length).toBe(Math.floor(sub.byteLength / 4)); + }); + + it('I8 from ArrayBuffer and view', () => { + const buf = new ArrayBuffer(4); + const dv = new DataView(buf); + dv.setInt8(0, -5); + dv.setInt8(1, 120); + dv.setInt8(2, -128); + dv.setInt8(3, 127); + + const a = I8(buf); + expect(Array.from(a)).toEqual([-5, 120, -128, 127]); + + const sub = new Int8Array(buf, 1, 2); + const s = I8(sub); + expect(Array.from(s)).toEqual([120, -128]); + }); + + it('I16 from ArrayBuffer and misaligned view throws', () => { + const buf = new ArrayBuffer(6); + const dv = new DataView(buf); + dv.setInt16(0, -12345, true); + dv.setInt16(2, 12345, true); + dv.setInt16(4, 32767, true); + + const a16 = I16(buf); + expect(a16.length).toBe(3); + expect(a16[0]).toBe(-12345); + expect(a16[1]).toBe(12345); + expect(a16[2]).toBe(32767); + + const view = new Uint8Array(buf, 1, 4); + expect(() => I16(view)).toThrow(); + }); + + it('I32 from ArrayBuffer and misaligned view throws', () => { + const buf = new ArrayBuffer(12); + const dv = new DataView(buf); + dv.setInt32(0, -0x1234567, true); + dv.setInt32(4, 0x1234567, true); + + const a32 = I32(buf); + expect(a32[0]).toBe(-0x1234567); + expect(a32[1]).toBe(0x1234567); + + const view = new Uint8Array(buf, 1, 8); + expect(() => I32(view)).toThrow(); + }); + + it('F32/F64 from ArrayBuffer', () => { + const buf = new ArrayBuffer(16); + const dv = new DataView(buf); + dv.setFloat32(0, 3.14, true); + dv.setFloat32(4, -2.5, true); + dv.setFloat64(8, 1.23456789e3, true); + + const f32 = F32(buf); + expect(f32[0]).toBeCloseTo(3.14, 5); + expect(f32[1]).toBeCloseTo(-2.5, 5); + + const f64 = F64(buf); + expect(f64[1]).toBeCloseTo(1.23456789e3, 8); + }); + + it('isBytes', () => { + expect(isBytes(new Uint8Array([1,2]))).toBe(true); + expect(isBytes(new Uint16Array([1,2]) as unknown)).toBe(false); + expect(isBytes(new ArrayBuffer(2) as unknown)).toBe(false); + }) + + it('assertBytes', () => { + // assertBytes accepts a Uint8Array + expect(() => assertBytes(new Uint8Array([1,2]))).not.toThrow(); + // assertBytes with length mismatch throws + expect(() => assertBytes(new Uint8Array([1,2]), 3)).toThrow(); + // assertBytes with wrong type throws + expect(() => assertBytes(new Uint16Array([1,2]) as any)).toThrow(); + }); + + it('hexToBytes / bytesToHex roundtrip', () => { + for (const v of staticHexVectors) { + expect(hexToBytes(v.hex)).toEqual(v.bytes); + expect(hexToBytes(v.hex.toUpperCase())).toEqual(v.bytes); + expect(bytesToHex(v.bytes)).toEqual(v.hex); + // encode -> decode + const h = bytesToHex(v.bytes); + expect(hexToBytes(h)).toEqual(v.bytes); + } + }); + + it('base64 encodings roundtrip and variants', () => { + const hello = new Uint8Array([72,101,108,108,111]); // 'Hello' + const b64 = bytesToBase64(hello, 'base64'); + expect(base64ToBytes(b64)).toEqual(hello); + + const b64raw = bytesToBase64(hello, 'base64raw'); + expect(base64ToBytes(b64raw)).toEqual(hello); + + const b64url = bytesToBase64(hello, 'base64url'); + expect(base64ToBytes(b64url)).toEqual(hello); + + const b64urlraw = bytesToBase64(hello, 'base64urlraw'); + expect(base64ToBytes(b64urlraw)).toEqual(hello); + }); + + it('encodeBytes / decodeBytes round robin across encodings', () => { + const bytes = Uint8Array.from([0xde,0xad,0xbe,0xef]); + const encodings = ['hex','base64','base64raw','base64url','base64urlraw'] as const; + for (const enc of encodings) { + const s = encodeBytes(bytes, enc as any); + const out = decodeBytes(s, enc as any); + expect(out).toEqual(bytes); + } + }); + + it('throws on invalid inputs for hex/base64 decoders', () => { + expect(() => hexToBytes('z')).toThrow(); + expect(() => base64ToBytes('??')).toThrow(); + expect(() => decodeBytes('abc', 'unsupported' as any)).toThrow(); + }); + + it('edge cases: offsets, odd lengths, url base64, empty and large roundtrips', () => { + // U16/U32 with subarray offset + const buf = new ArrayBuffer(6); + const v = new Uint8Array(buf); + v.set([1,2,3,4,5,6]); + const view = new Uint8Array(buf, 1, 4); // [2,3,4,5] + const u8 = U8(view); + expect(Array.from(u8)).toEqual([2,3,4,5]); + // Unaligned views cannot be reinterpreted as 16-bit/32-bit arrays on some + // platforms; U16/U32 will throw for misaligned offsets. Assert that behavior. + expect(() => U16(view)).toThrow(); + + // hexToBytes with odd length should throw + expect(() => hexToBytes('f')).toThrow(); + + // base64 url-safe without padding should be accepted + const sample = new Uint8Array([0x01,0x02,0x03,0xff]); + const raw = bytesToBase64(sample, 'base64raw'); + // url variant + const url = bytesToBase64(sample, 'base64urlraw'); + expect(base64ToBytes(raw)).toEqual(sample); + expect(base64ToBytes(url)).toEqual(sample); + + // empty inputs + expect(bytesToHex(new Uint8Array([]))).toBe(''); + expect(bytesToBase64(new Uint8Array([]), 'base64')).toBe(''); + + // large random roundtrip + const large = new Uint8Array(10000); + for (let i = 0; i < large.length; i++) large[i] = i & 0xff; + const h = bytesToHex(large); + expect(hexToBytes(h)).toEqual(large); + const b64 = bytesToBase64(large, 'base64raw'); + expect(base64ToBytes(b64)).toEqual(large); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..58936bb --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,163 @@ +export type TypedArray = Int8Array | Uint8ClampedArray | Uint8Array | Uint16Array | Int16Array | Uint32Array | Int32Array; + +export const U8 = (arr: ArrayBuffer | TypedArray): Uint8Array => { + if (arr instanceof ArrayBuffer) return new Uint8Array(arr); + return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength); +} + +export const I8 = (arr: ArrayBuffer | TypedArray): Int8Array => { + if (arr instanceof ArrayBuffer) return new Int8Array(arr); + return new Int8Array(arr.buffer, arr.byteOffset, arr.byteLength); +} + +export const U16 = (arr: ArrayBuffer | TypedArray): Uint16Array => { + if (arr instanceof ArrayBuffer) return new Uint16Array(arr); + return new Uint16Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 2)); +} + +export const I16 = (arr: ArrayBuffer | TypedArray): Int16Array => { + if (arr instanceof ArrayBuffer) return new Int16Array(arr); + return new Int16Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 2)); +} + +export const U32 = (arr: ArrayBuffer | TypedArray): Uint32Array => { + if (arr instanceof ArrayBuffer) return new Uint32Array(arr); + return new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4)); +} + +export const I32 = (arr: ArrayBuffer | TypedArray): Int32Array => { + if (arr instanceof ArrayBuffer) return new Int32Array(arr); + return new Int32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4)); +} + +export const F32 = (arr: ArrayBuffer | TypedArray): Float32Array => { + if (arr instanceof ArrayBuffer) return new Float32Array(arr); + return new Float32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4)); +} + +export const F64 = (arr: ArrayBuffer | TypedArray): Float64Array => { + if (arr instanceof ArrayBuffer) return new Float64Array(arr); + return new Float64Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 8)); +} + +export const isBytes = (bytes: unknown): boolean => { + return bytes instanceof Uint8Array || (ArrayBuffer.isView(bytes) && bytes.constructor.name === "Uint8Array"); +} + +export const assertBytes = (bytes: Uint8Array, length?: number, name: string = ""): void => { + const valid = isBytes(bytes); + const sized = (typeof length !== 'undefined') ? (bytes.byteLength === length) : true; + if (!valid || !sized) { + const expected = typeof length !== 'undefined' ? `Uint8Array of length ${length}` : 'Uint8Array'; + const actual = valid ? `Uint8Array of length ${bytes.byteLength}` : typeof bytes; + throw new TypeError(`Expected ${name} to be ${expected}, got ${actual}`); + } +} + +const hasHexMethods = (() => + // @ts-ignore testing for builtins + 'toHex' in Uint8Array.from([]) && typeof Uint8Array.from([])['toHex'] === 'function' && + 'fromHex' in Uint8Array && typeof Uint8Array.fromHex === 'function')(); + +// Array where index 0xf0 (240) is mapped to string 'f0' +const hexes = Array.from({ length: 256 }, (_, i) => + i.toString(16).padStart(2, '0') +); + +export type HexEncoding = 'hex'; +export type Base64Encoding = 'base64'; +export type Base64RawEncoding = 'base64raw'; +export type Base64URLEncoding = 'base64url'; +export type Base64RawURLEncoding = 'base64urlraw'; +export type Encoding = HexEncoding | Base64Encoding | Base64RawEncoding | Base64URLEncoding | Base64RawURLEncoding; + +export const bytesToHex = (bytes: Uint8Array, length?: number): string => { + assertBytes(bytes, (typeof length !== 'undefined') ? length * 2 : undefined, "bytes"); + let hex = ''; + for (const byte of bytes) { + hex += hexes[byte]; + } + return hex; +}; + +export const hexToBytes = (hex: string): Uint8Array => { + if (typeof hex !== 'string' || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) { + throw new TypeError(`Expected hex string of even length, got ${hex}`); + } + if (hasHexMethods) { + // @ts-ignore using built-in hex methods if available + return Uint8Array.fromHex(hex); + } + + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} + +export const bytesToBase64 = (bytes: Uint8Array, encoding: Base64Encoding | Base64RawEncoding | Base64URLEncoding | Base64RawURLEncoding): string => { + assertBytes(bytes, (typeof length !== 'undefined') ? length * 2 : undefined, "bytes"); + const binary = String.fromCharCode(...bytes); + switch (encoding) { + case 'base64': + return btoa(binary); + case 'base64raw': + return btoa(binary).replace(/=+$/, ''); + case 'base64url': + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_'); + case 'base64urlraw': + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + default: + throw new TypeError(`Unsupported encoding: ${encoding}`); + } +} + +export const base64ToBytes = (b64: string): Uint8Array => { + if (typeof b64 !== "string") { + throw new TypeError(`Expected base64 string, got ${b64}`); + } + // Accept URL-safe base64 by replacing '-' with '+' and '_' with '/' + let normalized = b64.replace(/-/g, "+").replace(/_/g, "/"); + // Pad with '=' to make length a multiple of 4 + if (normalized.length % 4 !== 0) { + normalized += "=".repeat(4 - (normalized.length % 4)); + } + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) { + throw new TypeError(`Expected base64 string, got ${b64}`); + } + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +export const decodeBytes = (encoded: string, encoding: Encoding): Uint8Array => { + switch (encoding) { + case 'hex': + return hexToBytes(encoded); + case 'base64': + case 'base64raw': + case 'base64url': + case 'base64urlraw': + return base64ToBytes(encoded); + default: + throw new TypeError(`Unsupported encoding: ${encoding}`); + } +} + +export const encodeBytes = (bytes: Uint8Array, encoding: Encoding): string => { + switch (encoding) { + case 'hex': + return bytesToHex(bytes); + case 'base64': + case 'base64raw': + case 'base64url': + case 'base64urlraw': + return bytesToBase64(bytes, encoding); + default: + throw new TypeError(`Unsupported encoding: ${encoding}`); + } +}