@aokiapp/tlv
Version:
Tag-Length-Value (TLV) parser and builder library with schema support. Provides both parsing and building APIs as submodules.
281 lines (280 loc) • 10.8 kB
JavaScript
import { BasicTLVBuilder } from "./basic-builder.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 builder that builds TLV data based on a given schema (synchronous or asynchronous).
* @template S - The schema type.
*/
export class SchemaBuilder {
schema;
strict;
/**
* Constructs a SchemaBuilder for the specified schema.
* @param schema - The TLV schema to use.
*/
constructor(schema, options) {
this.schema = schema;
this.strict = options?.strict ?? true;
}
/**
* Builds data either in synchronous or asynchronous mode.
* @param data - The input data matching the schema structure.
* @param options - If { async: true }, builds asynchronously; otherwise synchronously.
* @returns Either a built ArrayBuffer or a Promise of a built ArrayBuffer.
*/
build(data, options) {
const prevStrict = this.strict;
if (options?.strict !== undefined) {
this.strict = options.strict;
}
try {
if (options?.async) {
return this.buildAsync(data);
}
else {
return this.buildSync(data);
}
}
finally {
this.strict = prevStrict;
}
}
/**
* Builds data in synchronous mode.
* @param data - The input data.
* @returns Built TLV result.
*/
buildSync(data) {
return this.buildWithSchemaSync(this.schema, data);
}
/**
* Builds data in asynchronous mode.
* @param data - The input data.
* @returns A Promise of built TLV result.
*/
async buildAsync(data) {
return await this.buildWithSchemaAsync(this.schema, data);
}
/**
* Recursively builds data in synchronous mode.
* @param schema - The schema to build with.
* @param data - The data to build.
* @returns Built result.
*/
buildWithSchemaSync(schema, data) {
if (isConstructedSchema(schema)) {
let fieldsToProcess = [...schema.fields];
// For SET, sort fields by tag as required by DER strict mode
if (schema.tagNumber === 17 &&
(schema.tagClass === TagClass.Universal ||
schema.tagClass === undefined) &&
this.strict) {
fieldsToProcess = fieldsToProcess.slice().sort((a, b) => {
// Encode tag bytes for comparison
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 childrenBuffers = fieldsToProcess.map((fieldSchema) => {
const fieldName = fieldSchema.name;
const fieldData = data[fieldName];
if (fieldData === undefined) {
throw new Error(`Missing required field: ${fieldName}`);
}
return this.buildWithSchemaSync(fieldSchema, fieldData);
});
// Avoid unnecessary ArrayBuffer copies
const totalLength = childrenBuffers.reduce((sum, buf) => sum + buf.byteLength, 0);
const childrenData = new Uint8Array(totalLength);
let offset = 0;
for (const buffer of childrenBuffers) {
const bufView = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
childrenData.set(bufView, offset);
offset += bufView.byteLength;
}
return BasicTLVBuilder.build({
tag: {
tagClass: schema.tagClass ?? TagClass.Universal,
tagNumber: schema.tagNumber ?? 16, // Default to SEQUENCE for constructed
constructed: true,
},
length: childrenData.byteLength,
value: childrenData.buffer,
endOffset: 0,
});
}
else {
// PrimitiveTLVSchema
let value;
if (schema.encode) {
const encoded = schema.encode(data);
if (encoded instanceof Promise) {
throw new Error(`Asynchronous encoder used in synchronous build for field: ${schema.name}`);
}
value = encoded;
}
else {
if (!(data instanceof ArrayBuffer)) {
throw new Error(`Field '${schema.name}' requires an ArrayBuffer, but received other type.`);
}
value = data;
}
return BasicTLVBuilder.build({
tag: {
tagClass: schema.tagClass ?? TagClass.Universal,
tagNumber: schema.tagNumber ?? 0,
constructed: false,
},
length: value.byteLength,
value: value,
endOffset: 0,
});
}
}
/**
* Recursively builds data in asynchronous mode.
* @param schema - The schema to build with.
* @param data - The data to build.
* @returns A Promise of the built result.
*/
async buildWithSchemaAsync(schema, data) {
if (isConstructedSchema(schema)) {
const fieldsToProcess = [...schema.fields];
// For SET, sort fields by tag as required by DER
if ((schema.tagNumber === 17 && schema.tagClass === TagClass.Universal) ||
(schema.tagNumber === 17 && schema.tagClass === undefined)) {
fieldsToProcess.sort((a, b) => {
const tagA = a.tagNumber ?? 0;
const tagB = b.tagNumber ?? 0;
return tagA - tagB;
});
}
const childBuffers = await Promise.all(fieldsToProcess.map((fieldSchema) => {
const fieldName = fieldSchema.name;
const fieldData = data[fieldName];
if (fieldData === undefined) {
throw new Error(`Missing required field: ${fieldName}`);
}
return this.buildWithSchemaAsync(fieldSchema, fieldData);
}));
const totalLength = childBuffers.reduce((sum, buf) => sum + buf.byteLength, 0);
const childrenData = new Uint8Array(totalLength);
let offset = 0;
for (const buffer of childBuffers) {
childrenData.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
}
return BasicTLVBuilder.build({
tag: {
tagClass: schema.tagClass ?? TagClass.Universal,
tagNumber: schema.tagNumber ?? 16, // Default to SEQUENCE for constructed
constructed: true,
},
length: childrenData.byteLength,
value: childrenData.buffer,
endOffset: 0,
});
}
else {
// PrimitiveTLVSchema
let value;
if (schema.encode) {
value = await Promise.resolve(schema.encode(data));
}
else {
if (!(data instanceof ArrayBuffer)) {
throw new Error(`Field '${schema.name}' requires an ArrayBuffer, but received other type.`);
}
value = data;
}
return BasicTLVBuilder.build({
tag: {
tagClass: schema.tagClass ?? TagClass.Universal,
tagNumber: schema.tagNumber ?? 0,
constructed: false,
},
length: value.byteLength,
value: value,
endOffset: 0,
});
}
}
}
/**
* Utility class for creating new TLV schemas (identical to parser schemas).
*/
export class Schema {
// 実装
static primitive(name, encode, options) {
const { tagClass, tagNumber } = options ?? {};
return {
name,
encode,
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,
};
}
}