From 4299a8cc35807c07ae391761c71954a3305086cf Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 25 Aug 2026 16:19:06 +0200 Subject: [PATCH] Add TLV and command tables with per-command parameter types --- src/defs/commands.ts | 265 ++++++++++++++++++++++++++++ src/defs/encodings.ts | 30 ++-- src/defs/tlvs.ts | 110 ++++++++++++ src/defs/types.ts | 399 +++++++++++++++++++++++++----------------- test/commands.test.ts | 88 ++++++++++ test/types.test.ts | 65 ++++--- tsconfig.json | 1 + 7 files changed, 761 insertions(+), 197 deletions(-) create mode 100644 src/defs/commands.ts create mode 100644 src/defs/tlvs.ts create mode 100644 test/commands.test.ts diff --git a/src/defs/commands.ts b/src/defs/commands.ts new file mode 100644 index 0000000..ad345ff --- /dev/null +++ b/src/defs/commands.ts @@ -0,0 +1,265 @@ +import type { ParamValue, WireType } from './types.ts'; +import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from './types.ts'; + +type CommandSpec = { + defaults?: Record; + id: number; + params?: Record; + tlvMap?: Record; +}; + +const bindParams = { + system_id: cstring, + password: cstring, + system_type: cstring, + interface_version: int8, + addr_ton: int8, + addr_npi: int8, + address_range: cstring, +} as const; + +/** + * Key order inside each `params` object is the order the fields appear on the wire. Reordering + * them corrupts every PDU of that command. + */ +const specs = { + alert_notification: { + id: 0x00000102, + params: { + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + esme_addr_ton: int8, + esme_addr_npi: int8, + esme_addr: cstring, + }, + }, + bind_receiver: { defaults: { interface_version: 0x50 }, id: 0x00000001, params: bindParams }, + bind_receiver_resp: { id: 0x80000001, params: { system_id: cstring } }, + bind_transmitter: { defaults: { interface_version: 0x50 }, id: 0x00000002, params: bindParams }, + bind_transmitter_resp: { id: 0x80000002, params: { system_id: cstring } }, + bind_transceiver: { defaults: { interface_version: 0x50 }, id: 0x00000009, params: bindParams }, + bind_transceiver_resp: { id: 0x80000009, params: { system_id: cstring } }, + broadcast_sm: { + id: 0x00000111, + params: { + service_type: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + message_id: cstring, + priority_flag: int8, + schedule_delivery_time: cstring, + validity_period: cstring, + replace_if_present_flag: int8, + data_coding: int8, + sm_default_msg_id: int8, + }, + }, + broadcast_sm_resp: { + id: 0x80000111, + params: { message_id: cstring }, + tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' }, + }, + cancel_broadcast_sm: { + id: 0x00000113, + params: { + service_type: cstring, + message_id: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + }, + }, + cancel_broadcast_sm_resp: { id: 0x80000113 }, + cancel_sm: { + id: 0x00000008, + params: { + service_type: cstring, + message_id: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + dest_addr_ton: int8, + dest_addr_npi: int8, + destination_addr: cstring, + }, + }, + cancel_sm_resp: { id: 0x80000008 }, + data_sm: { + id: 0x00000103, + params: { + service_type: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + dest_addr_ton: int8, + dest_addr_npi: int8, + destination_addr: cstring, + esm_class: int8, + registered_delivery: int8, + data_coding: int8, + }, + }, + data_sm_resp: { id: 0x80000103, params: { message_id: cstring } }, + deliver_sm: { + id: 0x00000005, + params: { + service_type: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + dest_addr_ton: int8, + dest_addr_npi: int8, + destination_addr: cstring, + esm_class: int8, + protocol_id: int8, + priority_flag: int8, + schedule_delivery_time: cstring, + validity_period: cstring, + registered_delivery: int8, + replace_if_present_flag: int8, + data_coding: int8, + sm_default_msg_id: int8, + sm_length: int8, + short_message: buffer, + }, + }, + deliver_sm_resp: { id: 0x80000005, params: { message_id: cstring } }, + enquire_link: { id: 0x00000015 }, + enquire_link_resp: { id: 0x80000015 }, + generic_nack: { id: 0x80000000 }, + outbind: { id: 0x0000000B, params: { system_id: cstring, password: cstring } }, + query_broadcast_sm: { + id: 0x00000112, + params: { + message_id: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + }, + }, + query_broadcast_sm_resp: { id: 0x80000112, params: { message_id: cstring } }, + query_sm: { + id: 0x00000003, + params: { + message_id: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + }, + }, + query_sm_resp: { + id: 0x80000003, + params: { + message_id: cstring, + final_date: cstring, + message_state: int8, + error_code: int8, + }, + }, + replace_sm: { + id: 0x00000007, + params: { + message_id: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + schedule_delivery_time: cstring, + validity_period: cstring, + registered_delivery: int8, + sm_default_msg_id: int8, + sm_length: int8, + short_message: buffer, + }, + }, + replace_sm_resp: { id: 0x80000007 }, + submit_multi: { + id: 0x00000021, + params: { + service_type: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + dest_address: dest_address_array, + esm_class: int8, + protocol_id: int8, + priority_flag: int8, + schedule_delivery_time: cstring, + validity_period: cstring, + registered_delivery: int8, + replace_if_present_flag: int8, + data_coding: int8, + sm_default_msg_id: int8, + short_message: buffer, + }, + }, + submit_multi_resp: { + id: 0x80000021, + params: { message_id: cstring, unsuccess_sme: unsuccess_sme_array }, + }, + submit_sm: { + id: 0x00000004, + params: { + service_type: cstring, + source_addr_ton: int8, + source_addr_npi: int8, + source_addr: cstring, + dest_addr_ton: int8, + dest_addr_npi: int8, + destination_addr: cstring, + esm_class: int8, + protocol_id: int8, + priority_flag: int8, + schedule_delivery_time: cstring, + validity_period: cstring, + registered_delivery: int8, + replace_if_present_flag: int8, + data_coding: int8, + sm_default_msg_id: int8, + sm_length: int8, + short_message: buffer, + }, + }, + submit_sm_resp: { id: 0x80000004, params: { message_id: cstring } }, + unbind: { id: 0x00000006 }, + unbind_resp: { id: 0x80000006 }, +} satisfies Record; + +export type CommandName = keyof typeof specs; + +type ParamsSpecOf = (typeof specs)[C] extends { params: infer P } ? P : Record; + +/** Parameters as they come off the wire: every field the command defines, always present. */ +export type PduParams = { + [K in keyof ParamsSpecOf]: ParamsSpecOf[K] extends WireType ? V : never; +}; + +/** Parameters callers supply: all optional, and numbers are accepted for the string fields. */ +export type PduParamsInput = { + [K in keyof ParamsSpecOf]?: ParamsSpecOf[K] extends WireType + ? V extends string ? number | string : V + : never; +}; + +export type CommandDefinition = CommandSpec & { command: string }; + +export const cmds: Record = {}; +export const cmdsById: Record = {}; + +for (const [command, spec] of Object.entries(specs)) { + const definition: CommandDefinition = { ...spec, command }; + + cmds[command] = definition; + cmdsById[spec.id] = definition; +} + +export function isCommandName(value: unknown): value is CommandName { + return typeof value === 'string' && Object.hasOwn(specs, value); +} + +export function commandNameById(id: number): CommandName | undefined { + const command = cmdsById[id]?.command; + + return isCommandName(command) ? command : undefined; +} diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts index 6caabf0..d91a55a 100644 --- a/src/defs/encodings.ts +++ b/src/defs/encodings.ts @@ -15,21 +15,31 @@ const gsmRegex = const gsmExtended = /[\f^{}\\[~\]|€]/g; const gsmEscaped = /\x1B([\nΛ()/<=>¡e])/g; +// Characters reachable only via an ESC prefix, paired with the base character that follows it. +const gsmExtendedPairs: [string, string][] = [ + ['\f', '\n'], + ['^', 'Λ'], + ['{', '('], + ['}', ')'], + ['\\', '/'], + ['[', '<'], + ['~', '='], + [']', '>'], + ['|', '¡'], + ['€', 'e'], +]; + const gsmCharCodes = new Map(); const gsmExtChars = new Map(); -for (let i = 0; i < gsmChars.length; i++) { - gsmCharCodes.set(gsmChars[i], i); +// Indexed by code unit rather than code point: every entry in the table is one octet on the wire. +for (let code = 0; code < gsmChars.length; code++) { + gsmCharCodes.set(gsmChars.charAt(code), code); } -{ - const from = '\f^{}\\[~]|€'; - const to = '\nΛ()/<=>¡e'; - - for (let i = 0; i < from.length; i++) { - gsmExtChars.set(from[i], to[i]); - gsmExtChars.set(to[i], from[i]); - } +for (const [extended, base] of gsmExtendedPairs) { + gsmExtChars.set(extended, base); + gsmExtChars.set(base, extended); } const ascii: Encoding = { diff --git a/src/defs/tlvs.ts b/src/defs/tlvs.ts new file mode 100644 index 0000000..39670a3 --- /dev/null +++ b/src/defs/tlvs.ts @@ -0,0 +1,110 @@ +import type { ParamValue, WireType } from './types.ts'; +import { tlv } from './types.ts'; + +export type TlvDefinition = { + id: number; + multiple?: boolean; + tag: string; + type: WireType; +}; + +type TlvSpec = { id: number; multiple?: boolean; type: WireType }; + +// Ordered by tag id, mirroring the SMPP 5.0 TLV table. +const specs = { + dest_addr_subunit: { id: 0x0005, type: tlv.int8 }, + dest_network_type: { id: 0x0006, type: tlv.int8 }, + dest_bearer_type: { id: 0x0007, type: tlv.int8 }, + dest_telematics_id: { id: 0x0008, type: tlv.int16 }, + source_addr_subunit: { id: 0x000D, type: tlv.int8 }, + source_network_type: { id: 0x000E, type: tlv.int8 }, + source_bearer_type: { id: 0x000F, type: tlv.int8 }, + source_telematics_id: { id: 0x0010, type: tlv.int16 }, + qos_time_to_live: { id: 0x0017, type: tlv.int32 }, + payload_type: { id: 0x0019, type: tlv.int8 }, + additional_status_info_text: { id: 0x001D, type: tlv.cstring }, + receipted_message_id: { id: 0x001E, type: tlv.cstring }, + ms_msg_wait_facilities: { id: 0x0030, type: tlv.int8 }, + privacy_indicator: { id: 0x0201, type: tlv.int8 }, + source_subaddress: { id: 0x0202, type: tlv.buffer }, + dest_subaddress: { id: 0x0203, type: tlv.buffer }, + user_message_reference: { id: 0x0204, type: tlv.int16 }, + user_response_code: { id: 0x0205, type: tlv.int8 }, + source_port: { id: 0x020A, type: tlv.int16 }, + dest_port: { id: 0x020B, type: tlv.int16 }, + sar_msg_ref_num: { id: 0x020C, type: tlv.int16 }, + language_indicator: { id: 0x020D, type: tlv.int8 }, + sar_total_segments: { id: 0x020E, type: tlv.int8 }, + sar_segment_seqnum: { id: 0x020F, type: tlv.int8 }, + sc_interface_version: { id: 0x0210, type: tlv.int8 }, + callback_num_pres_ind: { id: 0x0302, multiple: true, type: tlv.int8 }, + callback_num_atag: { id: 0x0303, multiple: true, type: tlv.buffer }, + number_of_messages: { id: 0x0304, type: tlv.int8 }, + callback_num: { id: 0x0381, multiple: true, type: tlv.buffer }, + dpf_result: { id: 0x0420, type: tlv.int8 }, + set_dpf: { id: 0x0421, type: tlv.int8 }, + ms_availability_status: { id: 0x0422, type: tlv.int8 }, + network_error_code: { id: 0x0423, type: tlv.buffer }, + message_payload: { id: 0x0424, type: tlv.buffer }, + delivery_failure_reason: { id: 0x0425, type: tlv.int8 }, + more_messages_to_send: { id: 0x0426, type: tlv.int8 }, + message_state: { id: 0x0427, type: tlv.int8 }, + congestion_state: { id: 0x0428, type: tlv.int8 }, + ussd_service_op: { id: 0x0501, type: tlv.int8 }, + broadcast_channel_indicator: { id: 0x0600, type: tlv.int8 }, + broadcast_content_type: { id: 0x0601, type: tlv.buffer }, + broadcast_content_type_info: { id: 0x0602, type: tlv.string }, + broadcast_message_class: { id: 0x0603, type: tlv.int8 }, + broadcast_rep_num: { id: 0x0604, type: tlv.int16 }, + broadcast_frequency_interval: { id: 0x0605, type: tlv.buffer }, + broadcast_area_identifier: { id: 0x0606, multiple: true, type: tlv.buffer }, + broadcast_error_status: { id: 0x0607, multiple: true, type: tlv.int32 }, + broadcast_area_success: { id: 0x0608, type: tlv.int8 }, + broadcast_end_time: { id: 0x0609, type: tlv.string }, + broadcast_service_group: { id: 0x060A, type: tlv.string }, + billing_identification: { id: 0x060B, type: tlv.buffer }, + source_network_id: { id: 0x060D, type: tlv.cstring }, + dest_network_id: { id: 0x060E, type: tlv.cstring }, + source_node_id: { id: 0x060F, type: tlv.string }, + dest_node_id: { id: 0x0610, type: tlv.string }, + dest_addr_np_resolution: { id: 0x0611, type: tlv.int8 }, + dest_addr_np_information: { id: 0x0612, type: tlv.string }, + dest_addr_np_country: { id: 0x0613, type: tlv.int32 }, + display_time: { id: 0x1201, type: tlv.int8 }, + sms_signal: { id: 0x1203, type: tlv.int16 }, + ms_validity: { id: 0x1204, type: tlv.buffer }, + alert_on_message_delivery: { id: 0x130C, type: tlv.int8 }, + its_reply_type: { id: 0x1380, type: tlv.int8 }, + its_session_info: { id: 0x1383, type: tlv.buffer }, +} satisfies Record; + +export type TlvName = keyof typeof specs; + +export const tlvs: Record = {}; +export const tlvsById: Record = {}; + +for (const [tag, spec] of Object.entries(specs)) { + const definition: TlvDefinition = { ...spec, tag }; + + tlvs[tag] = definition; + tlvsById[spec.id] = definition; +} + +// Alternate spellings that resolve to the same tag; the definition keeps its canonical name. +const aliases: Record = { + alert_on_msg_delivery: 'alert_on_message_delivery', + failed_broadcast_area_identifier: 'broadcast_area_identifier', +}; + +for (const [alias, target] of Object.entries(aliases)) { + tlvs[alias] = { ...specs[target], tag: target }; +} + +/** Fallback for tags this table does not know: keep the raw octets. */ +export const tlvDefault: WireType = tlv.buffer; + +export type Tlv = { + tagId: number; + tagName: string | undefined; + tagValue: ParamValue; +}; diff --git a/src/defs/types.ts b/src/defs/types.ts index 798b775..e7b6528 100644 --- a/src/defs/types.ts +++ b/src/defs/types.ts @@ -13,28 +13,106 @@ export type UnsuccessSme = { export type ParamValue = Buffer | DestAddress[] | UnsuccessSme[] | number | string; -export type WireType = { - default: TRead; - read: (buffer: Buffer, offset: number, length?: number) => Result<{ value: TRead }>; - size: (value: TWrite) => number; - write: (value: TWrite, buffer: Buffer, offset: number) => VoidResult; +/** + * 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; }; 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)} bytes at offset ${String(offset)} of a ${String(buffer.length)} byte buffer`, + `Out of range: need ${String(needed)} octets at offset ${String(offset)} of a ${String(buffer.length)} octet buffer`, ); } return undefined; } -function asString(value: number | string): string { - return typeof value === 'number' ? value.toString() : value; +function wantInt(value: ParamValue, max: number): Result<{ int: number }> { + if (typeof value !== 'number' || !Number.isInteger(value)) { + return { err: new Error(`Expected an integer, got ${JSON.stringify(value)}`) }; + } + + if (value < 0 || value > max) { + return { err: new Error(`Integer ${String(value)} out of range 0-${String(max)}`) }; + } + + return { int: value }; } -function readCstring(buffer: Buffer, offset: number): Result<{ value: string }> { +function wantText(value: ParamValue): Result<{ text: string }> { + if (typeof value === 'string') return { text: value }; + if (typeof value === 'number') return { text: value.toString() }; + + return { err: new Error(`Expected a string, got ${typeof value}`) }; +} + +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, 'ascii') }; +} + +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 }> { let length = 0; while (buffer[offset + length]) { @@ -45,90 +123,53 @@ function readCstring(buffer: Buffer, offset: number): Result<{ value: string }> } } - return { value: buffer.toString('ascii', offset, offset + length) }; + return { bytesRead: length + 1, value: buffer.toString('ascii', offset, offset + length) }; } -function writeCstring(value: number | string, buffer: Buffer, offset: number): VoidResult { - const str = asString(value); - const err = outOfRange(buffer, offset, str.length + 1); +function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult { + const err = outOfRange(buffer, offset, text.length + 1); if (err) return { err }; - buffer.write(str, offset, 'ascii'); - buffer[offset + str.length] = 0; + buffer.write(text, offset, 'ascii'); + buffer[offset + text.length] = 0; return {}; } -function sizeCstring(value: number | string): number { - return asString(value).length + 1; +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: WireType = { - default: 0, - read(buffer, offset) { - const err = outOfRange(buffer, offset, 1); +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)); - return err ? { err } : { value: buffer.readUInt8(offset) }; - }, - size() { - return 1; - }, - write(value, buffer, offset) { - const err = outOfRange(buffer, offset, 1); - - if (err) return { err }; - - buffer.writeUInt8(value || 0, offset); - - return {}; - }, -}; - -export const int16: WireType = { - default: 0, - read(buffer, offset) { - const err = outOfRange(buffer, offset, 2); - - return err ? { err } : { value: buffer.readUInt16BE(offset) }; - }, - size() { - return 2; - }, - write(value, buffer, offset) { - const err = outOfRange(buffer, offset, 2); - - if (err) return { err }; - - buffer.writeUInt16BE(value || 0, offset); - - return {}; - }, -}; - -export const int32: WireType = { - default: 0, - read(buffer, offset) { - const err = outOfRange(buffer, offset, 4); - - return err ? { err } : { value: buffer.readUInt32BE(offset) }; - }, - size() { - return 4; - }, - write(value, buffer, offset) { - const err = outOfRange(buffer, offset, 4); - - if (err) return { err }; - - buffer.writeUInt32BE(value || 0, offset); - - return {}; - }, -}; - -/** Octet String: a length byte followed by that many octets. */ -export const string: WireType = { +/** Octet String: a length octet followed by that many octets. */ +export const string: WireType = { default: '', read(buffer, offset) { const lengthErr = outOfRange(buffer, offset, 1); @@ -140,58 +181,82 @@ export const string: WireType = { if (err) return { err }; - return { value: buffer.toString('ascii', offset + 1, offset + 1 + length) }; + return { bytesRead: length + 1, value: buffer.toString('ascii', offset + 1, offset + 1 + length) }; }, size(value) { - return asString(value).length + 1; + const { err, text } = wantText(value); + + return err ? { err } : { size: text.length + 1 }; }, write(value, buffer, offset) { - const str = asString(value); - const err = outOfRange(buffer, offset, str.length + 1); + const { err, text } = wantText(value); if (err) return { err }; - buffer.writeUInt8(str.length, offset); - buffer.write(str, offset + 1, 'ascii'); + const rangeErr = outOfRange(buffer, offset, text.length + 1); + + if (rangeErr) return { err: rangeErr }; + + buffer.writeUInt8(text.length, offset); + buffer.write(text, offset + 1, 'ascii'); return {}; }, }; /** C-Octet String: NULL-terminated. */ -export const cstring: WireType = { +export const cstring: WireType = { default: '', read: readCstring, - size: sizeCstring, - write: writeCstring, + size(value) { + const { err, text } = wantText(value); + + return err ? { err } : { size: text.length + 1 }; + }, + write(value, buffer, offset) { + const { err, text } = wantText(value); + + return err ? { err } : writeCstring(text, buffer, offset); + }, }; -export const buffer: WireType = { +export const buffer: WireType = { default: Buffer.alloc(0), read(buf, offset, length = 0) { const err = outOfRange(buf, offset, length); - return err ? { err } : { value: buf.subarray(offset, offset + length) }; + return err ? { err } : { bytesRead: length, value: buf.subarray(offset, offset + length) }; }, - // A trailing NULL octet is not counted, mirroring how peers that append one to short_message - // report sm_length. pduToObj relies on this when deciding whether to retry a parse. size(value) { - const buf = Buffer.isBuffer(value) ? value : Buffer.from(asString(value), 'ascii'); + const { bytes, err } = wantBytes(value); - return buf[buf.length - 1] === 0x00 ? buf.length - 1 : buf.length; + return err ? { err } : { size: bytes.length }; }, write(value, buf, offset) { - const source = Buffer.isBuffer(value) ? value : Buffer.from(asString(value), 'ascii'); - const err = outOfRange(buf, offset, source.length); + const { bytes, err } = wantBytes(value); if (err) return { err }; - source.copy(buf, offset); + 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) { @@ -199,7 +264,8 @@ export const dest_address_array: WireType = { if (countErr) return { err: countErr }; - const result: DestAddress[] = []; + const start = offset; + const value: DestAddress[] = []; let remaining = buf.readUInt8(offset++); while (remaining-- > 0) { @@ -216,53 +282,51 @@ export const dest_address_array: WireType = { const dest_addr_ton = buf.readUInt8(offset++); const dest_addr_npi = buf.readUInt8(offset++); - const { err, value } = readCstring(buf, offset); + const address = readCstring(buf, offset); - if (err) return { err }; + if (address.err) return { err: address.err }; - offset += sizeCstring(value); - result.push({ dest_addr_npi, dest_addr_ton, destination_addr: value }); + offset += address.bytesRead; + value.push({ dest_addr_npi, dest_addr_ton, destination_addr: address.value }); } else { - const { err, value } = readCstring(buf, offset); + const name = readCstring(buf, offset); - if (err) return { err }; + if (name.err) return { err: name.err }; - offset += sizeCstring(value); - result.push({ dl_name: value }); + offset += name.bytesRead; + value.push({ dl_name: name.value }); } } - return { value: result }; + return { bytesRead: offset - start, value }; }, size(value) { - let size = 1; + const { addresses, err } = wantDestAddresses(value); - for (const dest of value) { - size += 'dl_name' in dest - ? sizeCstring(dest.dl_name) + 1 - : sizeCstring(dest.destination_addr) + 3; - } - - return size; + return err ? { err } : { size: sizeDestAddresses(addresses) }; }, write(value, buf, offset) { - const err = outOfRange(buf, offset, dest_address_array.size(value)); + const { addresses, err } = wantDestAddresses(value); if (err) return { err }; - buf.writeUInt8(value.length, offset++); + const rangeErr = outOfRange(buf, offset, sizeDestAddresses(addresses)); - for (const dest of value) { + if (rangeErr) return { err: rangeErr }; + + buf.writeUInt8(addresses.length, offset++); + + for (const dest of addresses) { if ('dl_name' in dest) { buf.writeUInt8(2, offset++); writeCstring(dest.dl_name, buf, offset); - offset += sizeCstring(dest.dl_name); + offset += dest.dl_name.length + 1; } else { buf.writeUInt8(1, offset++); - buf.writeUInt8(dest.dest_addr_ton || 0, offset++); - buf.writeUInt8(dest.dest_addr_npi || 0, offset++); + buf.writeUInt8(dest.dest_addr_ton, offset++); + buf.writeUInt8(dest.dest_addr_npi, offset++); writeCstring(dest.destination_addr, buf, offset); - offset += sizeCstring(dest.destination_addr); + offset += dest.destination_addr.length + 1; } } @@ -270,6 +334,16 @@ export const dest_address_array: WireType = { }, }; +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) { @@ -277,7 +351,8 @@ export const unsuccess_sme_array: WireType = { if (countErr) return { err: countErr }; - const result: UnsuccessSme[] = []; + const start = offset; + const value: UnsuccessSme[] = []; let remaining = buf.readUInt8(offset++); while (remaining-- > 0) { @@ -287,48 +362,48 @@ export const unsuccess_sme_array: WireType = { const dest_addr_ton = buf.readUInt8(offset++); const dest_addr_npi = buf.readUInt8(offset++); - const { err, value } = readCstring(buf, offset); + const address = readCstring(buf, offset); - if (err) return { err }; + if (address.err) return { err: address.err }; - offset += sizeCstring(value); + offset += address.bytesRead; const statusErr = outOfRange(buf, offset, 4); if (statusErr) return { err: statusErr }; - result.push({ + value.push({ dest_addr_npi, dest_addr_ton, - destination_addr: value, + destination_addr: address.value, error_status_code: buf.readUInt32BE(offset), }); offset += 4; } - return { value: result }; + return { bytesRead: offset - start, value }; }, size(value) { - let size = 1; + const { err, smes } = wantUnsuccessSmes(value); - for (const sme of value) { - size += sizeCstring(sme.destination_addr) + 6; - } - - return size; + return err ? { err } : { size: sizeUnsuccessSmes(smes) }; }, write(value, buf, offset) { - const err = outOfRange(buf, offset, unsuccess_sme_array.size(value)); + const { err, smes } = wantUnsuccessSmes(value); if (err) return { err }; - buf.writeUInt8(value.length, offset++); + const rangeErr = outOfRange(buf, offset, sizeUnsuccessSmes(smes)); - for (const sme of value) { - buf.writeUInt8(sme.dest_addr_ton || 0, offset++); - buf.writeUInt8(sme.dest_addr_npi || 0, offset++); + if (rangeErr) return { err: rangeErr }; + + buf.writeUInt8(smes.length, offset++); + + for (const sme of smes) { + buf.writeUInt8(sme.dest_addr_ton, offset++); + buf.writeUInt8(sme.dest_addr_npi, offset++); writeCstring(sme.destination_addr, buf, offset); - offset += sizeCstring(sme.destination_addr); + offset += sme.destination_addr.length + 1; buf.writeUInt32BE(sme.error_status_code, offset); offset += 4; } @@ -337,45 +412,39 @@ export const unsuccess_sme_array: WireType = { }, }; -/** TLV variants are length-prefixed by the TLV header, so they carry no length of their own. */ +/** TLV variants carry no length of their own; the TLV header supplies it. */ export const tlv = { - buffer: { - default: Buffer.alloc(0), - read(buf: Buffer, offset: number, length = 0): Result<{ value: Buffer }> { - const err = outOfRange(buf, offset, length); - - return err ? { err } : { value: buf.subarray(offset, offset + length) }; - }, - size(value: Buffer | number | string): number { - return Buffer.isBuffer(value) ? value.length : asString(value).length; - }, - write: buffer.write, - } satisfies WireType, + buffer, cstring, int8, int16, int32, string: { default: '', - read(buf: Buffer, offset: number, length = 0): Result<{ value: string }> { + read(buf: Buffer, offset: number, length = 0) { const err = outOfRange(buf, offset, length); - return err ? { err } : { value: buf.toString('ascii', offset, offset + length) }; + return err ? { err } : { bytesRead: length, value: buf.toString('ascii', offset, offset + length) }; }, - size(value: number | string): number { - return asString(value).length; + size(value: ParamValue) { + const { err, text } = wantText(value); + + return err ? { err } : { size: text.length }; }, - write(value: number | string, buf: Buffer, offset: number): VoidResult { - const str = asString(value); - const err = outOfRange(buf, offset, str.length); + write(value: ParamValue, buf: Buffer, offset: number) { + const { err, text } = wantText(value); if (err) return { err }; - buf.write(str, offset, 'ascii'); + const rangeErr = outOfRange(buf, offset, text.length); + + if (rangeErr) return { err: rangeErr }; + + buf.write(text, offset, 'ascii'); return {}; }, - } satisfies WireType, + } satisfies WireType, }; export const types = { diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 0000000..94b738a --- /dev/null +++ b/test/commands.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import type { PduParams, PduParamsInput } from '../src/defs/commands.ts'; +import { cmds, cmdsById, commandNameById, isCommandName } from '../src/defs/commands.ts'; + +describe('command table', () => { + test('every command is reachable by name and by id', () => { + assert.equal(cmds.submit_sm?.id, 0x00000004); + assert.equal(cmds.submit_sm_resp?.id, 0x80000004); + assert.equal(cmdsById[0x00000004]?.command, 'submit_sm'); + assert.equal(cmdsById[0x80000009]?.command, 'bind_transceiver_resp'); + }); + + test('covers all 33 commands', () => { + assert.equal(Object.keys(cmds).length, 33); + }); + + test('bind commands default interface_version to 0x50', () => { + assert.equal(cmds.bind_transceiver?.defaults?.interface_version, 0x50); + }); + + // Wire order, not alphabetical order — reordering these corrupts every PDU. + test('submit_sm parameters are in wire order', () => { + assert.deepEqual(Object.keys(cmds.submit_sm?.params ?? {}), [ + 'service_type', + 'source_addr_ton', + 'source_addr_npi', + 'source_addr', + 'dest_addr_ton', + 'dest_addr_npi', + 'destination_addr', + 'esm_class', + 'protocol_id', + 'priority_flag', + 'schedule_delivery_time', + 'validity_period', + 'registered_delivery', + 'replace_if_present_flag', + 'data_coding', + 'sm_default_msg_id', + 'sm_length', + 'short_message', + ]); + }); + + test('resolves names from ids and guards unknown ones', () => { + assert.equal(commandNameById(0x00000004), 'submit_sm'); + assert.equal(commandNameById(0x0BADF00D), undefined); + assert.ok(isCommandName('deliver_sm')); + assert.ok(!isCommandName('deliver_pizza')); + }); +}); + +describe('per-command parameter types', () => { + test('narrow to the fields the command actually defines', () => { + const params: PduParams<'submit_sm'> = { + data_coding: 0, + destination_addr: '46709771337', + dest_addr_npi: 0, + dest_addr_ton: 1, + esm_class: 0, + priority_flag: 0, + protocol_id: 0, + registered_delivery: 0, + replace_if_present_flag: 0, + schedule_delivery_time: '', + service_type: '', + short_message: Buffer.from('hello'), + sm_default_msg_id: 0, + sm_length: 5, + source_addr: '46701113311', + source_addr_npi: 0, + source_addr_ton: 1, + validity_period: '', + }; + + // Fails to compile if short_message is not known to be a Buffer. + assert.equal(params.short_message.toString(), 'hello'); + // Fails to compile if destination_addr is not known to be a string. + assert.equal(params.destination_addr.length, 11); + }); + + test('accept a partial input, coercing numbers into the string fields', () => { + const input: PduParamsInput<'submit_sm_resp'> = { message_id: 2343 }; + + assert.equal(input.message_id, 2343); + }); +}); diff --git a/test/types.test.ts b/test/types.test.ts index ede7381..4dc158d 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -8,31 +8,37 @@ describe('integers', () => { const source = Buffer.from([0, 0x65]); const target = Buffer.alloc(1); - assert.deepEqual(types.int8.read(source, 1), { value: 0x65 }); - assert.equal(types.int8.size(0x65), 1); + assert.deepEqual(types.int8.read(source, 1), { bytesRead: 1, value: 0x65 }); + assert.deepEqual(types.int8.size(0x65), { size: 1 }); assert.deepEqual(types.int8.write(0x65, target, 0), {}); assert.deepEqual(target, Buffer.from([0x65])); }); - test('int16 reads, sizes and writes two octets big-endian', () => { + test('int16 reads and writes two octets big-endian', () => { const source = Buffer.from([0, 0x05, 0x65]); const target = Buffer.alloc(2); - assert.deepEqual(types.int16.read(source, 1), { value: 0x0565 }); - assert.equal(types.int16.size(0x0565), 2); + assert.deepEqual(types.int16.read(source, 1), { bytesRead: 2, value: 0x0565 }); types.int16.write(0x0565, target, 0); assert.deepEqual(target, Buffer.from([0x05, 0x65])); }); - test('int32 reads, sizes and writes four octets big-endian', () => { + test('int32 reads and writes four octets big-endian', () => { const source = Buffer.from([0, 0x10, 0x02, 0x40, 0x45]); const target = Buffer.alloc(4); - assert.deepEqual(types.int32.read(source, 1), { value: 0x10024045 }); - assert.equal(types.int32.size(0x10024045), 4); + assert.deepEqual(types.int32.read(source, 1), { bytesRead: 4, value: 0x10024045 }); types.int32.write(0x10024045, target, 0); assert.deepEqual(target, Buffer.from([0x10, 0x02, 0x40, 0x45])); }); + + // 0.4.0 let Node throw straight out of writeUInt8 for these. + test('rejects values the field cannot hold', () => { + assert.ok(types.int8.write(256, Buffer.alloc(1), 0).err instanceof Error); + assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error); + assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error); + assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error); + }); }); describe('string (Octet String)', () => { @@ -40,11 +46,11 @@ describe('string (Octet String)', () => { const encoded = Buffer.concat([Buffer.from([8]), Buffer.from(expected)]); test('reads a length-prefixed string', () => { - assert.deepEqual(types.string.read(encoded, 0), { value: expected }); + assert.deepEqual(types.string.read(encoded, 0), { bytesRead: 9, value: expected }); }); test('sizes as the string plus its length octet', () => { - assert.equal(types.string.size(expected), 9); + assert.deepEqual(types.string.size(expected), { size: 9 }); }); test('writes a length-prefixed string', () => { @@ -61,11 +67,11 @@ describe('cstring (C-Octet String)', () => { const encoded = Buffer.concat([Buffer.from(expected), Buffer.from([0])]); test('reads a NULL-terminated string', () => { - assert.deepEqual(types.cstring.read(encoded, 0), { value: expected }); + assert.deepEqual(types.cstring.read(encoded, 0), { bytesRead: 9, value: expected }); }); test('sizes as the string plus its NULL terminator', () => { - assert.equal(types.cstring.size(expected), 9); + assert.deepEqual(types.cstring.size(expected), { size: 9 }); }); test('writes a NULL-terminated string', () => { @@ -82,13 +88,11 @@ describe('cstring (C-Octet String)', () => { types.cstring.write(123, target, 0); assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00])); - assert.equal(types.cstring.size(123), 4); + assert.deepEqual(types.cstring.size(123), { size: 4 }); }); test('refuses a string with no terminator rather than running off the end', () => { - const { err } = types.cstring.read(Buffer.from('abcd'), 0); - - assert.ok(err instanceof Error); + assert.ok(types.cstring.read(Buffer.from('abcd'), 0).err instanceof Error); }); }); @@ -96,11 +100,21 @@ describe('buffer', () => { const expected = Buffer.from('abcd1234'); test('reads a binary field of the given length', () => { - assert.deepEqual(types.buffer.read(expected, 0, expected.length), { value: expected }); + assert.deepEqual(types.buffer.read(expected, 0, expected.length), { + bytesRead: 8, + value: expected, + }); }); test('sizes a binary field in octets', () => { - assert.equal(types.buffer.size(expected), 8); + assert.deepEqual(types.buffer.size(expected), { size: 8 }); + }); + + // 0.4.0 subtracted one whenever the last octet was 0x00, so a UCS2 message ending in a + // character like U+4E00 was allocated one octet short while sm_length still reported the full + // length — the PDU went out corrupt. + test('counts a trailing NULL octet like any other', () => { + assert.deepEqual(types.buffer.size(Buffer.from([0x4E, 0x00])), { size: 2 }); }); test('writes a binary field', () => { @@ -124,11 +138,14 @@ describe('dest_address_array', () => { ]; test('reads every dest_address structure', () => { - assert.deepEqual(types.dest_address_array.read(encoded, 0), { value: expected }); + assert.deepEqual(types.dest_address_array.read(encoded, 0), { + bytesRead: 13, + value: expected, + }); }); test('sizes every dest_address structure', () => { - assert.equal(types.dest_address_array.size(expected), 13); + assert.deepEqual(types.dest_address_array.size(expected), { size: 13 }); }); test('writes every dest_address structure', () => { @@ -152,11 +169,14 @@ describe('unsuccess_sme_array', () => { ]; test('reads every unsuccess_sme structure', () => { - assert.deepEqual(types.unsuccess_sme_array.read(encoded, 0), { value: expected }); + assert.deepEqual(types.unsuccess_sme_array.read(encoded, 0), { + bytesRead: 21, + value: expected, + }); }); test('sizes every unsuccess_sme structure', () => { - assert.equal(types.unsuccess_sme_array.size(expected), 21); + assert.deepEqual(types.unsuccess_sme_array.size(expected), { size: 21 }); }); test('writes every unsuccess_sme structure', () => { @@ -174,6 +194,7 @@ describe('bounds checking', () => { assert.ok(types.int32.read(Buffer.alloc(2), 0).err instanceof Error); assert.ok(types.int8.read(Buffer.alloc(1), 5).err instanceof Error); assert.ok(types.string.read(Buffer.from([10, 0x61]), 0).err instanceof Error); + assert.ok(types.dest_address_array.read(Buffer.from([0x05, 0x01]), 0).err instanceof Error); }); test('writing past the end returns an error instead of throwing', () => { diff --git a/tsconfig.json b/tsconfig.json index f4d9a10..969cf2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, "rewriteRelativeImportExtensions": true,