@aokiapp/tlv
Version:
Tag-Length-Value (TLV) parser and builder library with schema support. Provides both parsing and building APIs as submodules.
157 lines (156 loc) • 5.76 kB
JavaScript
import { inferIsSetFromTag, TagClass } from "../common/index";
import { BasicTLVBuilder } from "./basic-builder.js";
/**
* A TLV builder that encodes data according to a provided schema.
* Strict mode enforces presence of all required fields and type expectations.
*/
export class SchemaBuilder {
schema;
strict;
constructor(schema, options) {
this.schema = schema;
this.strict = options?.strict ?? true;
}
// Encodes the supplied data into TLV using the schema rules.
build(data) {
return this.encodeTopLevel(this.schema, data);
}
encodeTopLevel(schema, data) {
if (this.isConstructed(schema)) {
return this.encodeConstructed(schema, (data ?? {}));
}
if (this.isRepeated(schema)) {
// Top-level repeated has no tag to wrap items; disallow to keep TLV well-formed.
throw new Error(`Top-level repeated schema '${schema.name}' is not supported. Wrap it in a constructed container.`);
}
return this.encodePrimitive(schema, data);
}
encodePrimitive(schema, data) {
const { tagNumber, tagClass } = schema;
const value = schema.encode(data);
return BasicTLVBuilder.build({
tag: { tagClass, constructed: false, tagNumber },
length: value.byteLength,
value,
endOffset: 0,
});
}
encodeConstructed(schema, data) {
const { tagNumber, tagClass } = schema;
const childBuffers = [];
for (const field of schema.fields) {
const fieldName = field.name;
const v = data[fieldName];
// Missing property handling
if (v === undefined) {
if (field.optional) {
continue;
}
throw new Error(`Missing required property '${fieldName}' in constructed '${schema.name}'`);
}
if (this.isRepeated(field)) {
if (!Array.isArray(v)) {
throw new Error(`Repeated field '${fieldName}' expects an array`);
}
const items = v;
for (const item of items) {
const itemTLV = this.encodeTopLevel(field.item, item);
childBuffers.push(itemTLV);
}
continue;
}
if (this.isConstructed(field)) {
const childTLV = this.encodeConstructed(field, v);
childBuffers.push(childTLV);
continue;
}
// Primitive child
const primTLV = this.encodePrimitive(field, v);
childBuffers.push(primTLV);
}
// For SET, enforce DER canonical ordering when strict=true; preserve input order when strict=false
if (schema.isSet === true && this.strict === true) {
childBuffers.sort((a, b) => this.compareUnsignedLex(a, b));
}
const inner = this.concatBuffers(childBuffers);
return BasicTLVBuilder.build({
tag: { tagClass, constructed: true, tagNumber },
length: inner.byteLength,
value: inner,
endOffset: 0,
});
}
isConstructed(schema) {
return Object.prototype.hasOwnProperty.call(schema, "fields");
}
isRepeated(schema) {
return Object.prototype.hasOwnProperty.call(schema, "item");
}
concatBuffers(buffers) {
const total = buffers.reduce((sum, b) => sum + b.byteLength, 0);
const out = new Uint8Array(total);
let off = 0;
for (const b of buffers) {
out.set(new Uint8Array(b), off);
off += b.byteLength;
}
return out.buffer;
}
// Unsigned lexicographic comparator for raw DER bytes (a < b => negative, a > b => positive)
compareUnsignedLex(a, b) {
const ua = new Uint8Array(a);
const ub = new Uint8Array(b);
const len = Math.min(ua.length, ub.length);
for (let i = 0; i < len; i++) {
if (ua[i] !== ub[i])
return ua[i] - ub[i];
}
return ua.length - ub.length;
}
}
/**
* Utility class for creating new TLV schemas (identical to parser schemas).
*/
// Convenience factory for constructing schema descriptors used by the builder.
export class Schema {
static primitive(name, options, encode) {
const tagNumber = options.tagNumber;
if (typeof tagNumber !== "number") {
throw new Error(`Primitive schema '${name}' requires tagNumber`);
}
const obj = {
name,
encode,
tagClass: options?.tagClass ?? TagClass.Universal,
tagNumber,
optional: options?.optional ? true : false,
};
return obj;
}
static constructed(name, options, fields) {
const tagClassNormalized = options?.tagClass ?? TagClass.Universal;
const inferredIsSet = options?.isSet !== undefined
? options.isSet
: inferIsSetFromTag(tagClassNormalized, options?.tagNumber);
const inferredTagNumber = inferredIsSet ? 17 : 16;
const obj = {
name,
fields,
tagClass: tagClassNormalized,
tagNumber: options?.tagNumber ?? inferredTagNumber,
optional: options?.optional ? true : false,
isSet: inferredIsSet,
};
return obj;
}
static repeated(name, options, item) {
const obj = {
name,
item,
tagClass: options?.tagClass ?? TagClass.Universal,
tagNumber: options?.tagNumber,
optional: options?.optional ? true : false,
};
return obj;
}
}