UNPKG

@aokiapp/tlv

Version:

Tag-Length-Value (TLV) parser and builder library with schema support. Provides both parsing and building APIs as submodules.

251 lines (250 loc) 9.68 kB
import { BasicTLVParser } from "./basic-parser.js"; import { TagClass } from "../common/types.js"; /** * Checks if a given schema is a constructed schema. * @param schema - A TLV schema object. * @returns True if the schema has fields; false otherwise. */ function isConstructedSchema(schema) { return ("fields" in schema && Array.isArray(schema.fields)); } /** * A parser that parses TLV data based on a given schema (synchronous or asynchronous). * @template S - The schema type. */ export class SchemaParser { schema; buffer = new ArrayBuffer(0); view = new DataView(this.buffer); offset = 0; strict; /** * Constructs a SchemaParser for the specified schema. * @param schema - The TLV schema to use. */ constructor(schema, options) { this.schema = schema; this.strict = options?.strict ?? false; } /** * Parses data either in synchronous or asynchronous mode. * @param buffer - The input data as an ArrayBuffer. * @param options - If { async: true }, parses asynchronously; otherwise synchronously. * @returns Either a parsed result or a Promise of a parsed result. */ parse(buffer, options) { const prevStrict = this.strict; if (options?.strict !== undefined) { this.strict = options.strict; } try { if (options?.async) { return this.parseAsync(buffer); } else { return this.parseSync(buffer); } } finally { this.strict = prevStrict; } } /** * Parses data in synchronous mode. * @param buffer - The input data. * @returns Parsed result matching the schema. */ parseSync(buffer) { this.buffer = buffer; this.view = new DataView(buffer); this.offset = 0; return this.parseWithSchemaSync(this.schema); } /** * Parses data in asynchronous mode. * @param buffer - The input data. * @returns A Promise of parsed result matching the schema. */ async parseAsync(buffer) { this.buffer = buffer; this.view = new DataView(buffer); this.offset = 0; return await this.parseWithSchemaAsync(this.schema); } /** * Recursively parses data in synchronous mode. * @param schema - The schema to parse with. * @returns Parsed result. */ parseWithSchemaSync(schema) { const subBuffer = this.buffer.slice(this.offset); const { tag, value, endOffset } = BasicTLVParser.parse(subBuffer); this.offset += endOffset; this.validateTagInfo(tag, schema); if (isConstructedSchema(schema)) { let subOffset = 0; let fieldsToProcess = [...schema.fields]; // strictモード時、SET要素の順序をDER仕様で検証 if (schema.tagNumber === 17 && (schema.tagClass === TagClass.Universal || schema.tagClass === undefined) && this.strict) { fieldsToProcess = fieldsToProcess.slice().sort((a, b) => { const encodeTag = (field) => { const tagClass = field.tagClass ?? TagClass.Universal; const tagNumber = field.tagNumber ?? 0; const constructed = isConstructedSchema(field) ? 0x20 : 0x00; const bytes = []; let firstByte = (tagClass << 6) | constructed; if (tagNumber < 31) { firstByte |= tagNumber; bytes.push(firstByte); } else { firstByte |= 0x1f; bytes.push(firstByte); let num = tagNumber; const tagNumBytes = []; do { tagNumBytes.unshift(num % 128); num = Math.floor(num / 128); } while (num > 0); for (let i = 0; i < tagNumBytes.length - 1; i++) { bytes.push(tagNumBytes[i] | 0x80); } bytes.push(tagNumBytes[tagNumBytes.length - 1]); } return new Uint8Array(bytes); }; return compareUint8Arrays(encodeTag(a), encodeTag(b)); }); /** * Compare two Uint8Arrays lexicographically. * Returns -1 if a < b, 1 if a > b, 0 if equal. */ function compareUint8Arrays(a, b) { const len = Math.min(a.length, b.length); for (let i = 0; i < len; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } if (a.length !== b.length) return a.length < b.length ? -1 : 1; return 0; } } const result = {}; for (const field of fieldsToProcess) { const fieldParser = new SchemaParser(field, { strict: this.strict }); result[field.name] = fieldParser.parse(value.slice(subOffset)); subOffset += fieldParser.offset; } if (subOffset !== value.byteLength) { throw new Error("Constructed element does not end exactly at the expected length."); } return result; } else { if (schema.decode) { const decoded = schema.decode(value); if (decoded instanceof Promise || // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any decoded?.then instanceof Function) { throw new Error(`Asynchronous decoder used in synchronous parse for field: ${schema.name}`); } return decoded; } return value; } } /** * Recursively parses data in asynchronous mode. * @param schema - The schema to parse with. * @returns A Promise of the parsed result. */ async parseWithSchemaAsync(schema) { const subBuffer = this.buffer.slice(this.offset); const { tag, value, endOffset } = BasicTLVParser.parse(subBuffer); this.offset += endOffset; this.validateTagInfo(tag, schema); if (isConstructedSchema(schema)) { let subOffset = 0; const result = {}; for (const field of schema.fields) { const fieldParser = new SchemaParser(field); const parsedField = await fieldParser.parseAsync(value.slice(subOffset)); result[field.name] = parsedField; subOffset += fieldParser.offset; } if (subOffset !== value.byteLength) { throw new Error("Constructed element does not end exactly at the expected length."); } return result; } else { if (schema.decode) { // decode might return a Promise, so it is awaited const decoded = schema.decode(value); return (await Promise.resolve(decoded)); } return value; } } /** * Validates tag information against the expected schema. * @param tagInfo - The parsed tag info. * @param schema - The schema to validate. * @throws Error if tag class, tag number, or constructed status does not match. */ validateTagInfo(tagInfo, schema) { if (schema.tagClass !== undefined && schema.tagClass !== tagInfo.tagClass) { throw new Error(`Tag class mismatch: expected ${schema.tagClass}, but got ${tagInfo.tagClass}`); } const expectedConstructed = isConstructedSchema(schema); if (expectedConstructed !== tagInfo.constructed) { throw new Error(`Tag constructed flag mismatch: expected ${expectedConstructed}, but got ${tagInfo.constructed}`); } if (schema.tagNumber !== undefined && schema.tagNumber !== tagInfo.tagNumber) { throw new Error(`Tag number mismatch: expected ${schema.tagNumber}, but got ${tagInfo.tagNumber}`); } } } /** * Utility class for creating new TLV schemas. */ export class Schema { /** * Creates a primitive TLV schema definition. * @param name - The name of the field. * @param decode - Optional decode function. * @param options - Optional tag class and tag number. * @returns A primitive TLV schema object. */ static primitive(name, decode, options) { const { tagClass, tagNumber } = options ?? {}; return { name, decode, tagClass, tagNumber, }; } /** * Creates a constructed TLV schema definition. * @param name - The name of the field. * @param fields - An array of TLV schema definitions. * @param options - Optional tag class and tag number. * @returns A constructed TLV schema object. */ static constructed(name, fields, options) { const { tagClass, tagNumber } = options ?? {}; return { name, fields, tagClass, tagNumber, }; } }