@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
JavaScript
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,
};
}
}