1371137fef
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 34s
Test / test (24) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 5s
685 lines
18 KiB
TypeScript
685 lines
18 KiB
TypeScript
import type { Result, VoidResult } from '../result.ts';
|
|
import { unencodableText } from './encodings.ts';
|
|
|
|
export type DestAddress =
|
|
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
|
|
| { dl_name: string };
|
|
|
|
export type UnsuccessSme = {
|
|
dest_addr_npi: number;
|
|
dest_addr_ton: number;
|
|
destination_addr: string;
|
|
error_status_code: number;
|
|
};
|
|
|
|
export type ParamValue = Buffer | DestAddress[] | UnsuccessSme[] | number | string;
|
|
|
|
/** A tag defined `multiple` holds every occurrence, in wire order; any other tag holds one value. */
|
|
export type TlvValue = Buffer | Buffer[] | number | number[] | string;
|
|
|
|
/** Octets a value holds, counting a string by its length. */
|
|
export function tlvOctets(value: TlvValue): number {
|
|
if (Buffer.isBuffer(value)) return value.length;
|
|
if (typeof value === 'string') return value.length;
|
|
if (typeof value === 'number') return 0;
|
|
|
|
let octets = 0;
|
|
|
|
for (const one of value) {
|
|
octets += typeof one === 'number' ? 0 : one.length;
|
|
}
|
|
|
|
return octets;
|
|
}
|
|
|
|
/**
|
|
* One field on the wire. `read` reports how many octets it consumed so callers never have to
|
|
* re-derive a length that could disagree with what was actually written.
|
|
*/
|
|
export type WireType<T extends ParamValue = ParamValue> = {
|
|
default: T;
|
|
read: (buffer: Buffer, offset: number, length?: number) => Result<{ bytesRead: number; value: T }>;
|
|
size: (value: ParamValue) => Result<{ size: number }>;
|
|
write: (value: ParamValue, buffer: Buffer, offset: number) => VoidResult;
|
|
};
|
|
|
|
/** Renders a parameter as text without ever falling back to "[object Object]". */
|
|
export function paramText(value: ParamValue | TlvValue | undefined): string {
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'number') return value.toString();
|
|
if (Buffer.isBuffer(value)) return value.toString('latin1');
|
|
|
|
return '';
|
|
}
|
|
|
|
export function paramNumber(value: ParamValue | undefined, fallback: number): number {
|
|
return typeof value === 'number' ? value : fallback;
|
|
}
|
|
|
|
/** Spells a refused value for the caller who wrote it; JSON spells NaN and the infinities `null`. */
|
|
export function valueText(value: ParamValue): string {
|
|
return typeof value === 'number' ? String(value) : JSON.stringify(value);
|
|
}
|
|
|
|
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
|
|
if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
|
|
return new Error(
|
|
`Out of range: need ${String(needed)} octets at offset ${String(offset)} of a ${String(buffer.length)} octet buffer`,
|
|
);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
|
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
return { err: new Error(`Expected an integer, got ${valueText(value)}`) };
|
|
}
|
|
|
|
if (value < 0 || value > max) {
|
|
return { err: new Error(`Integer ${String(value)} out of range 0-${String(max)}`) };
|
|
}
|
|
|
|
return { int: value };
|
|
}
|
|
|
|
function writeInt8(value: ParamValue, buf: Buffer, offset: number): VoidResult {
|
|
const { err, int } = wantInt(value, 0xFF);
|
|
|
|
if (err) return { err };
|
|
|
|
buf.writeUInt8(int, offset);
|
|
|
|
return {};
|
|
}
|
|
|
|
function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult {
|
|
const { err, int } = wantInt(value, 0xFFFFFFFF);
|
|
|
|
if (err) return { err };
|
|
|
|
buf.writeUInt32BE(int, offset);
|
|
|
|
return {};
|
|
}
|
|
|
|
function pastLatin1(text: string): { err: Error } | undefined {
|
|
const index = text.search(/[\u0100-\uFFFF]/);
|
|
|
|
if (index === -1) return undefined;
|
|
|
|
const char = String.fromCodePoint(text.codePointAt(index) ?? 0);
|
|
|
|
return {
|
|
err: new Error(
|
|
`latin1 cannot carry ${unencodableText({ char, index })}, and every text field on the wire is written in it; strip or transliterate it`,
|
|
),
|
|
};
|
|
}
|
|
|
|
function wantText(value: ParamValue): Result<{ text: string }> {
|
|
if (typeof value !== 'number' && typeof value !== 'string') {
|
|
return { err: new Error(`Expected a string or a number, got ${typeof value}`) };
|
|
}
|
|
|
|
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
return { err: new Error(`Expected a finite number, got ${String(value)}`) };
|
|
}
|
|
|
|
const text = String(value);
|
|
|
|
return pastLatin1(text) ?? { text };
|
|
}
|
|
|
|
function wantCstringText(value: ParamValue): Result<{ text: string }> {
|
|
const { err, text } = wantText(value);
|
|
|
|
if (err) return { err };
|
|
|
|
const index = text.indexOf('\u0000');
|
|
|
|
if (index === -1) return { text };
|
|
|
|
return {
|
|
err: new Error(`U+0000 at index ${String(index)} would end the C-Octet String there`),
|
|
};
|
|
}
|
|
|
|
function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
|
|
if (Buffer.isBuffer(value)) return { bytes: value };
|
|
|
|
const { err, text } = wantText(value);
|
|
|
|
return err ? { err } : { bytes: Buffer.from(text, 'latin1') };
|
|
}
|
|
|
|
function isDestAddress(value: unknown): value is DestAddress {
|
|
if (typeof value !== 'object' || value === null) return false;
|
|
|
|
if ('dl_name' in value) return typeof value.dl_name === 'string';
|
|
|
|
return 'destination_addr' in value && typeof value.destination_addr === 'string';
|
|
}
|
|
|
|
function isUnsuccessSme(value: unknown): value is UnsuccessSme {
|
|
return typeof value === 'object'
|
|
&& value !== null
|
|
&& 'destination_addr' in value && typeof value.destination_addr === 'string'
|
|
&& 'error_status_code' in value && typeof value.error_status_code === 'number';
|
|
}
|
|
|
|
function wantDestAddresses(value: ParamValue): Result<{ addresses: DestAddress[] }> {
|
|
if (!Array.isArray(value)) {
|
|
return { err: new Error('Expected an array of dest_address structures') };
|
|
}
|
|
|
|
const addresses: DestAddress[] = [];
|
|
|
|
for (const entry of value) {
|
|
if (!isDestAddress(entry)) {
|
|
return { err: new Error('Expected an array of dest_address structures') };
|
|
}
|
|
|
|
addresses.push(entry);
|
|
}
|
|
|
|
return { addresses };
|
|
}
|
|
|
|
function wantUnsuccessSmes(value: ParamValue): Result<{ smes: UnsuccessSme[] }> {
|
|
if (!Array.isArray(value)) {
|
|
return { err: new Error('Expected an array of unsuccess_sme structures') };
|
|
}
|
|
|
|
const smes: UnsuccessSme[] = [];
|
|
|
|
for (const entry of value) {
|
|
if (!isUnsuccessSme(entry)) {
|
|
return { err: new Error('Expected an array of unsuccess_sme structures') };
|
|
}
|
|
|
|
smes.push(entry);
|
|
}
|
|
|
|
return { smes };
|
|
}
|
|
|
|
function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number; value: string }> {
|
|
if (outOfRange(buffer, offset, 0)) {
|
|
return {
|
|
err: new Error(
|
|
`C-Octet String starts at offset ${String(offset)}, past a ${String(buffer.length)} octet buffer`,
|
|
),
|
|
};
|
|
}
|
|
|
|
// An offset at the end exactly is an absent trailing field, which real peers do send.
|
|
if (offset === buffer.length) return { bytesRead: 0, value: '' };
|
|
|
|
let length = 0;
|
|
|
|
while (buffer[offset + length]) {
|
|
length++;
|
|
|
|
if (offset + length >= buffer.length) {
|
|
return { err: new Error('Unterminated C-Octet String') };
|
|
}
|
|
}
|
|
|
|
return { bytesRead: length + 1, value: buffer.toString('latin1', offset, offset + length) };
|
|
}
|
|
|
|
function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult {
|
|
const err = outOfRange(buffer, offset, text.length + 1);
|
|
|
|
if (err) return { err };
|
|
|
|
buffer.write(text, offset, 'latin1');
|
|
buffer[offset + text.length] = 0;
|
|
|
|
return {};
|
|
}
|
|
|
|
function intType(octets: number, max: number, readAt: (b: Buffer, o: number) => number, writeAt: (b: Buffer, v: number, o: number) => void): WireType<number> {
|
|
return {
|
|
default: 0,
|
|
read(buffer, offset) {
|
|
const err = outOfRange(buffer, offset, octets);
|
|
|
|
return err ? { err } : { bytesRead: octets, value: readAt(buffer, offset) };
|
|
},
|
|
size() {
|
|
return { size: octets };
|
|
},
|
|
write(value, buffer, offset) {
|
|
const rangeErr = outOfRange(buffer, offset, octets);
|
|
|
|
if (rangeErr) return { err: rangeErr };
|
|
|
|
const { err, int } = wantInt(value, max);
|
|
|
|
if (err) return { err };
|
|
|
|
writeAt(buffer, int, offset);
|
|
|
|
return {};
|
|
},
|
|
};
|
|
}
|
|
|
|
export const int8 = intType(1, 0xFF, (b, o) => b.readUInt8(o), (b, v, o) => b.writeUInt8(v, o));
|
|
export const int16 = intType(2, 0xFFFF, (b, o) => b.readUInt16BE(o), (b, v, o) => b.writeUInt16BE(v, o));
|
|
export const int32 = intType(4, 0xFFFFFFFF, (b, o) => b.readUInt32BE(o), (b, v, o) => b.writeUInt32BE(v, o));
|
|
|
|
const intByOctets: Record<number, WireType<number>> = { 1: int8, 2: int16, 4: int32 };
|
|
|
|
/**
|
|
* The TLV header's length is what the parser skips past, so it is also the width the value is read
|
|
* at — a peer that types a tag one octet wider than the table says still gets the value it meant.
|
|
*/
|
|
function tlvInt(declared: WireType<number>): WireType<number> {
|
|
return {
|
|
...declared,
|
|
read(buffer, offset, length) {
|
|
if (length === undefined) return declared.read(buffer, offset);
|
|
|
|
const width = intByOctets[length];
|
|
|
|
if (!width) {
|
|
return { err: new Error(`Integer TLV declares ${String(length)} octets, expected 1, 2 or 4`) };
|
|
}
|
|
|
|
return width.read(buffer, offset);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Octet String: a length octet followed by that many octets. */
|
|
export const string: WireType<string> = {
|
|
default: '',
|
|
read(buffer, offset) {
|
|
const lengthErr = outOfRange(buffer, offset, 1);
|
|
|
|
if (lengthErr) return { err: lengthErr };
|
|
|
|
const length = buffer.readUInt8(offset);
|
|
const err = outOfRange(buffer, offset + 1, length);
|
|
|
|
if (err) return { err };
|
|
|
|
return { bytesRead: length + 1, value: buffer.toString('latin1', offset + 1, offset + 1 + length) };
|
|
},
|
|
size(value) {
|
|
const { err, text } = wantText(value);
|
|
|
|
if (err) return { err };
|
|
|
|
return tooLongForLengthOctet(text) ?? { size: text.length + 1 };
|
|
},
|
|
write(value, buffer, offset) {
|
|
const { err, text } = wantText(value);
|
|
|
|
if (err) return { err };
|
|
|
|
const lengthErr = tooLongForLengthOctet(text);
|
|
|
|
if (lengthErr) return lengthErr;
|
|
|
|
const rangeErr = outOfRange(buffer, offset, text.length + 1);
|
|
|
|
if (rangeErr) return { err: rangeErr };
|
|
|
|
buffer.writeUInt8(text.length, offset);
|
|
buffer.write(text, offset + 1, 'latin1');
|
|
|
|
return {};
|
|
},
|
|
};
|
|
|
|
function tooLongForLengthOctet(text: string): { err: Error } | undefined {
|
|
if (text.length <= 0xFF) return undefined;
|
|
|
|
return { err: new Error(`Octet String is ${String(text.length)} octets, the length octet holds 255`) };
|
|
}
|
|
|
|
/** C-Octet String: NULL-terminated. */
|
|
export const cstring: WireType<string> = {
|
|
default: '',
|
|
read: readCstring,
|
|
size(value) {
|
|
const { err, text } = wantCstringText(value);
|
|
|
|
return err ? { err } : { size: text.length + 1 };
|
|
},
|
|
write(value, buffer, offset) {
|
|
const { err, text } = wantCstringText(value);
|
|
|
|
return err ? { err } : writeCstring(text, buffer, offset);
|
|
},
|
|
};
|
|
|
|
export const buffer: WireType<Buffer> = {
|
|
default: Buffer.alloc(0),
|
|
read(buf, offset, length = 0) {
|
|
const err = outOfRange(buf, offset, length);
|
|
|
|
return err ? { err } : { bytesRead: length, value: buf.subarray(offset, offset + length) };
|
|
},
|
|
size(value) {
|
|
const { bytes, err } = wantBytes(value);
|
|
|
|
return err ? { err } : { size: bytes.length };
|
|
},
|
|
write(value, buf, offset) {
|
|
const { bytes, err } = wantBytes(value);
|
|
|
|
if (err) return { err };
|
|
|
|
const rangeErr = outOfRange(buf, offset, bytes.length);
|
|
|
|
if (rangeErr) return { err: rangeErr };
|
|
|
|
bytes.copy(buf, offset);
|
|
|
|
return {};
|
|
},
|
|
};
|
|
|
|
function writeSizedCstring(value: ParamValue, buf: Buffer, offset: number): Result<{ size: number }> {
|
|
const { err, size } = cstring.size(value);
|
|
|
|
if (err) return { err };
|
|
|
|
const written = cstring.write(value, buf, offset);
|
|
|
|
return written.err ? { err: written.err } : { size };
|
|
}
|
|
|
|
function sizeDestAddresses(addresses: DestAddress[]): Result<{ size: number }> {
|
|
let size = 1;
|
|
|
|
for (const dest of addresses) {
|
|
const [header, text] = 'dl_name' in dest ? [1, cstring.size(dest.dl_name)] : [3, cstring.size(dest.destination_addr)];
|
|
|
|
if (text.err) return { err: text.err };
|
|
|
|
size += header + text.size;
|
|
}
|
|
|
|
return { size };
|
|
}
|
|
|
|
function writeDestAddress(dest: DestAddress, buf: Buffer, offset: number): Result<{ size: number }> {
|
|
if ('dl_name' in dest) {
|
|
buf.writeUInt8(2, offset);
|
|
|
|
const name = writeSizedCstring(dest.dl_name, buf, offset + 1);
|
|
|
|
return name.err ? { err: name.err } : { size: 1 + name.size };
|
|
}
|
|
|
|
buf.writeUInt8(1, offset);
|
|
|
|
const ton = writeInt8(dest.dest_addr_ton, buf, offset + 1);
|
|
|
|
if (ton.err) return { err: ton.err };
|
|
|
|
const npi = writeInt8(dest.dest_addr_npi, buf, offset + 2);
|
|
|
|
if (npi.err) return { err: npi.err };
|
|
|
|
const addr = writeSizedCstring(dest.destination_addr, buf, offset + 3);
|
|
|
|
return addr.err ? { err: addr.err } : { size: 3 + addr.size };
|
|
}
|
|
|
|
export const dest_address_array: WireType<DestAddress[]> = {
|
|
default: [],
|
|
read(buf, offset) {
|
|
const countErr = outOfRange(buf, offset, 1);
|
|
|
|
if (countErr) return { err: countErr };
|
|
|
|
const start = offset;
|
|
const value: DestAddress[] = [];
|
|
let remaining = buf.readUInt8(offset++);
|
|
|
|
while (remaining-- > 0) {
|
|
const flagErr = outOfRange(buf, offset, 1);
|
|
|
|
if (flagErr) return { err: flagErr };
|
|
|
|
const destFlag = buf.readUInt8(offset++);
|
|
|
|
if (destFlag === 1) {
|
|
const headerErr = outOfRange(buf, offset, 2);
|
|
|
|
if (headerErr) return { err: headerErr };
|
|
|
|
const dest_addr_ton = buf.readUInt8(offset++);
|
|
const dest_addr_npi = buf.readUInt8(offset++);
|
|
const address = readCstring(buf, offset);
|
|
|
|
if (address.err) return { err: address.err };
|
|
|
|
offset += address.bytesRead;
|
|
value.push({ dest_addr_npi, dest_addr_ton, destination_addr: address.value });
|
|
} else {
|
|
const name = readCstring(buf, offset);
|
|
|
|
if (name.err) return { err: name.err };
|
|
|
|
offset += name.bytesRead;
|
|
value.push({ dl_name: name.value });
|
|
}
|
|
}
|
|
|
|
return { bytesRead: offset - start, value };
|
|
},
|
|
size(value) {
|
|
const { addresses, err } = wantDestAddresses(value);
|
|
|
|
return err ? { err } : sizeDestAddresses(addresses);
|
|
},
|
|
write(value, buf, offset) {
|
|
const { addresses, err } = wantDestAddresses(value);
|
|
|
|
if (err) return { err };
|
|
|
|
const total = sizeDestAddresses(addresses);
|
|
|
|
if (total.err) return { err: total.err };
|
|
|
|
const rangeErr = outOfRange(buf, offset, total.size);
|
|
|
|
if (rangeErr) return { err: rangeErr };
|
|
|
|
const count = writeInt8(addresses.length, buf, offset++);
|
|
|
|
if (count.err) return { err: count.err };
|
|
|
|
for (const dest of addresses) {
|
|
const written = writeDestAddress(dest, buf, offset);
|
|
|
|
if (written.err) return { err: written.err };
|
|
|
|
offset += written.size;
|
|
}
|
|
|
|
return {};
|
|
},
|
|
};
|
|
|
|
function sizeUnsuccessSmes(smes: UnsuccessSme[]): Result<{ size: number }> {
|
|
let size = 1;
|
|
|
|
for (const sme of smes) {
|
|
const addr = cstring.size(sme.destination_addr);
|
|
|
|
if (addr.err) return { err: addr.err };
|
|
|
|
size += addr.size + 6;
|
|
}
|
|
|
|
return { size };
|
|
}
|
|
|
|
export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
|
default: [],
|
|
read(buf, offset) {
|
|
const countErr = outOfRange(buf, offset, 1);
|
|
|
|
if (countErr) return { err: countErr };
|
|
|
|
const start = offset;
|
|
const value: UnsuccessSme[] = [];
|
|
let remaining = buf.readUInt8(offset++);
|
|
|
|
while (remaining-- > 0) {
|
|
const headerErr = outOfRange(buf, offset, 2);
|
|
|
|
if (headerErr) return { err: headerErr };
|
|
|
|
const dest_addr_ton = buf.readUInt8(offset++);
|
|
const dest_addr_npi = buf.readUInt8(offset++);
|
|
const address = readCstring(buf, offset);
|
|
|
|
if (address.err) return { err: address.err };
|
|
|
|
offset += address.bytesRead;
|
|
|
|
const statusErr = outOfRange(buf, offset, 4);
|
|
|
|
if (statusErr) return { err: statusErr };
|
|
|
|
value.push({
|
|
dest_addr_npi,
|
|
dest_addr_ton,
|
|
destination_addr: address.value,
|
|
error_status_code: buf.readUInt32BE(offset),
|
|
});
|
|
offset += 4;
|
|
}
|
|
|
|
return { bytesRead: offset - start, value };
|
|
},
|
|
size(value) {
|
|
const { err, smes } = wantUnsuccessSmes(value);
|
|
|
|
return err ? { err } : sizeUnsuccessSmes(smes);
|
|
},
|
|
write(value, buf, offset) {
|
|
const { err, smes } = wantUnsuccessSmes(value);
|
|
|
|
if (err) return { err };
|
|
|
|
const total = sizeUnsuccessSmes(smes);
|
|
|
|
if (total.err) return { err: total.err };
|
|
|
|
const rangeErr = outOfRange(buf, offset, total.size);
|
|
|
|
if (rangeErr) return { err: rangeErr };
|
|
|
|
const count = writeInt8(smes.length, buf, offset++);
|
|
|
|
if (count.err) return { err: count.err };
|
|
|
|
for (const sme of smes) {
|
|
const ton = writeInt8(sme.dest_addr_ton, buf, offset++);
|
|
|
|
if (ton.err) return { err: ton.err };
|
|
|
|
const npi = writeInt8(sme.dest_addr_npi, buf, offset++);
|
|
|
|
if (npi.err) return { err: npi.err };
|
|
|
|
const addr = writeSizedCstring(sme.destination_addr, buf, offset);
|
|
|
|
if (addr.err) return { err: addr.err };
|
|
|
|
offset += addr.size;
|
|
|
|
const status = writeInt32(sme.error_status_code, buf, offset);
|
|
|
|
if (status.err) return { err: status.err };
|
|
|
|
offset += 4;
|
|
}
|
|
|
|
return {};
|
|
},
|
|
};
|
|
|
|
/** TLV variants carry no length of their own; the TLV header supplies it. */
|
|
export const tlv = {
|
|
buffer,
|
|
// Bounded by the TLV header length, and tolerant of peers that omit the NULL terminator.
|
|
cstring: {
|
|
default: '',
|
|
read(buf: Buffer, offset: number, length = 0) {
|
|
const err = outOfRange(buf, offset, length);
|
|
|
|
if (err) return { err };
|
|
|
|
const terminator = buf.indexOf(0, offset);
|
|
const end = terminator === -1 || terminator > offset + length
|
|
? offset + length
|
|
: terminator;
|
|
|
|
return { bytesRead: length, value: buf.toString('latin1', offset, end) };
|
|
},
|
|
size(value: ParamValue) {
|
|
const { err, text } = wantCstringText(value);
|
|
|
|
return err ? { err } : { size: text.length + 1 };
|
|
},
|
|
write(value: ParamValue, buf: Buffer, offset: number) {
|
|
const { err, text } = wantCstringText(value);
|
|
|
|
return err ? { err } : writeCstring(text, buf, offset);
|
|
},
|
|
} satisfies WireType<string>,
|
|
int8: tlvInt(int8),
|
|
int16: tlvInt(int16),
|
|
int32: tlvInt(int32),
|
|
string: {
|
|
default: '',
|
|
read(buf: Buffer, offset: number, length = 0) {
|
|
const err = outOfRange(buf, offset, length);
|
|
|
|
return err ? { err } : { bytesRead: length, value: buf.toString('latin1', offset, offset + length) };
|
|
},
|
|
size(value: ParamValue) {
|
|
const { err, text } = wantText(value);
|
|
|
|
return err ? { err } : { size: text.length };
|
|
},
|
|
write(value: ParamValue, buf: Buffer, offset: number) {
|
|
const { err, text } = wantText(value);
|
|
|
|
if (err) return { err };
|
|
|
|
const rangeErr = outOfRange(buf, offset, text.length);
|
|
|
|
if (rangeErr) return { err: rangeErr };
|
|
|
|
buf.write(text, offset, 'latin1');
|
|
|
|
return {};
|
|
},
|
|
} satisfies WireType<string>,
|
|
};
|
|
|
|
export const types = {
|
|
buffer,
|
|
cstring,
|
|
dest_address_array,
|
|
int8,
|
|
int16,
|
|
int32,
|
|
string,
|
|
tlv,
|
|
unsuccess_sme_array,
|
|
};
|