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 = { 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 { 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> = { 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): WireType { 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 = { 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 = { 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 = { 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 sizeDestAddresses(addresses: DestAddress[]): number { let size = 1; for (const dest of addresses) { size += 'dl_name' in dest ? dest.dl_name.length + 2 : dest.destination_addr.length + 4; } return size; } export const dest_address_array: WireType = { 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 } : { size: sizeDestAddresses(addresses) }; }, write(value, buf, offset) { const { addresses, err } = wantDestAddresses(value); if (err) return { err }; const rangeErr = outOfRange(buf, offset, sizeDestAddresses(addresses)); if (rangeErr) return { err: rangeErr }; const count = writeInt8(addresses.length, buf, offset++); if (count.err) return { err: count.err }; for (const dest of addresses) { if ('dl_name' in dest) { buf.writeUInt8(2, offset++); const name = cstring.write(dest.dl_name, buf, offset); if (name.err) return { err: name.err }; offset += dest.dl_name.length + 1; } else { buf.writeUInt8(1, offset++); const ton = writeInt8(dest.dest_addr_ton, buf, offset++); if (ton.err) return { err: ton.err }; const npi = writeInt8(dest.dest_addr_npi, buf, offset++); if (npi.err) return { err: npi.err }; const addr = cstring.write(dest.destination_addr, buf, offset); if (addr.err) return { err: addr.err }; offset += dest.destination_addr.length + 1; } } return {}; }, }; function sizeUnsuccessSmes(smes: UnsuccessSme[]): number { let size = 1; for (const sme of smes) { size += sme.destination_addr.length + 7; } return size; } export const unsuccess_sme_array: WireType = { 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 } : { size: sizeUnsuccessSmes(smes) }; }, write(value, buf, offset) { const { err, smes } = wantUnsuccessSmes(value); if (err) return { err }; const rangeErr = outOfRange(buf, offset, sizeUnsuccessSmes(smes)); 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 = cstring.write(sme.destination_addr, buf, offset); if (addr.err) return { err: addr.err }; offset += sme.destination_addr.length + 1; 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, 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, }; export const types = { buffer, cstring, dest_address_array, int8, int16, int32, string, tlv, unsuccess_sme_array, };