diff --git a/eslint.config.js b/eslint.config.js index 6e53bef..7d12fd5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,10 +15,20 @@ export default tseslint.config( }, rules: { '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/no-floating-promises': ['error', { + allowForKnownSafeCalls: [ + { from: 'package', name: ['describe', 'it', 'test'], package: 'node:test' }, + ], + }], '@typescript-eslint/no-non-null-assertion': 'error', 'no-console': 'error', }, }, + { + // ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns. + files: ['src/defs/encodings.ts'], + rules: { 'no-control-regex': 'off' }, + }, { files: ['eslint.config.js'], extends: [tseslint.configs.disableTypeChecked], diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts new file mode 100644 index 0000000..6caabf0 --- /dev/null +++ b/src/defs/encodings.ts @@ -0,0 +1,124 @@ +export type EncodingName = 'ASCII' | 'FLASH' | 'LATIN1' | 'UCS2'; + +export type Encoding = { + decode: (buffer: Uint8Array) => string; + encode: (value: string) => Buffer; + match: (value: string) => boolean; +}; + +const gsmChars = + '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'; + +const gsmRegex = + /^[@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&'()*+,\-./0-9:;<=>?¡A-ZÄÖÑܧ¿a-zäöñüà\f^{}\\[~\]|€]*$/; + +const gsmExtended = /[\f^{}\\[~\]|€]/g; +const gsmEscaped = /\x1B([\nΛ()/<=>¡e])/g; + +const gsmCharCodes = new Map(); +const gsmExtChars = new Map(); + +for (let i = 0; i < gsmChars.length; i++) { + gsmCharCodes.set(gsmChars[i], i); +} + +{ + 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]); + } +} + +const ascii: Encoding = { + decode(buffer) { + let result = ''; + + for (const byte of buffer) { + result += gsmChars[byte] ?? ' '; + } + + return result.replace(gsmEscaped, (match, escaped: string) => gsmExtChars.get(escaped) ?? match); + }, + + encode(value) { + const escaped = value.replace(gsmExtended, match => `\x1B${gsmExtChars.get(match) ?? ''}`); + const result: number[] = []; + + for (const char of escaped) { + result.push(gsmCharCodes.get(char) ?? 0x20); + } + + return Buffer.from(result); + }, + + match(value) { + return gsmRegex.test(value); + }, +}; + +const latin1: Encoding = { + decode(buffer) { + return Buffer.from(buffer).toString('latin1'); + }, + + encode(value) { + return Buffer.from(value, 'latin1'); + }, + + // Deliberately never selected by detect(); Latin-1 is decoded when a peer asks for it, never + // chosen for outgoing messages. + match() { + return false; + }, +}; + +const ucs2: Encoding = { + decode(buffer) { + return Buffer.from(buffer).swap16().toString('utf16le'); + }, + + encode(value) { + return Buffer.from(value, 'utf16le').swap16(); + }, + + match() { + return true; + }, +}; + +export const encodings: Record = { + ASCII: ascii, + FLASH: ascii, + LATIN1: latin1, + UCS2: ucs2, +}; + +export function detect(value: string): EncodingName { + if (encodings.ASCII.match(value)) return 'ASCII'; + if (encodings.LATIN1.match(value)) return 'LATIN1'; + + return 'UCS2'; +} + +/** + * SMPP data_coding is a flat table for 0x00-0x0E, but the 0x1X and 0xFX ranges carry a GSM message + * class and encode the alphabet in bits 3-2 (or bit 2) instead — which is how a flash UCS2 message + * arrives as 0x18. Alphabets with no codec here fall back to ASCII. + */ +export function encodingByDataCoding(dataCoding: number): EncodingName { + if ((dataCoding & 0xF0) === 0x10) { + return ((dataCoding >> 2) & 0x03) === 0x02 ? 'UCS2' : 'ASCII'; + } + + if ((dataCoding & 0xF0) === 0xF0) { + return 'ASCII'; + } + + if (dataCoding === 0x03) return 'LATIN1'; + if (dataCoding === 0x08) return 'UCS2'; + + return 'ASCII'; +} diff --git a/src/defs/errors.ts b/src/defs/errors.ts new file mode 100644 index 0000000..9615ee8 --- /dev/null +++ b/src/defs/errors.ts @@ -0,0 +1,88 @@ +// Ordered by code, mirroring the SMPP 5.0 command_status table, so gaps stay visible. +export const errors = { + ESME_ROK: 0x0000, + ESME_RINVMSGLEN: 0x0001, + ESME_RINVCMDLEN: 0x0002, + ESME_RINVCMDID: 0x0003, + ESME_RINVBNDSTS: 0x0004, + ESME_RALYBND: 0x0005, + ESME_RINVPRTFLG: 0x0006, + ESME_RINVREGDLVFLG: 0x0007, + ESME_RSYSERR: 0x0008, + ESME_RINVSRCADR: 0x000A, + ESME_RINVDSTADR: 0x000B, + ESME_RINVMSGID: 0x000C, + ESME_RBINDFAIL: 0x000D, + ESME_RINVPASWD: 0x000E, + ESME_RINVSYSID: 0x000F, + ESME_RCANCELFAIL: 0x0011, + ESME_RREPLACEFAIL: 0x0013, + ESME_RMSGQFUL: 0x0014, + ESME_RINVSERTYP: 0x0015, + ESME_RINVNUMDESTS: 0x0033, + ESME_RINVDLNAME: 0x0034, + ESME_RINVDESTFLAG: 0x0040, + ESME_RINVSUBREP: 0x0042, + ESME_RINVESMCLASS: 0x0043, + ESME_RCNTSUBDL: 0x0044, + ESME_RSUBMITFAIL: 0x0045, + ESME_RINVSRCTON: 0x0048, + ESME_RINVSRCNPI: 0x0049, + ESME_RINVDSTTON: 0x0050, + ESME_RINVDSTNPI: 0x0051, + ESME_RINVSYSTYP: 0x0053, + ESME_RINVREPFLAG: 0x0054, + ESME_RINVNUMMSGS: 0x0055, + ESME_RTHROTTLED: 0x0058, + ESME_RINVSCHED: 0x0061, + ESME_RINVEXPIRY: 0x0062, + ESME_RINVDFTMSGID: 0x0063, + ESME_RX_T_APPN: 0x0064, + ESME_RX_P_APPN: 0x0065, + ESME_RX_R_APPN: 0x0066, + ESME_RQUERYFAIL: 0x0067, + ESME_RINVTLVSTREAM: 0x00C0, + ESME_RTLVNOTALLWD: 0x00C1, + ESME_RINVTLVLEN: 0x00C2, + ESME_RMISSINGTLV: 0x00C3, + ESME_RINVTLVVAL: 0x00C4, + ESME_RDELIVERYFAILURE: 0x00FE, + ESME_RUNKNOWNERR: 0x00FF, + ESME_RSERTYPUNAUTH: 0x0100, + ESME_RPROHIBITED: 0x0101, + ESME_RSERTYPUNAVAIL: 0x0102, + ESME_RSERTYPDENIED: 0x0103, + ESME_RINVDCS: 0x0104, + ESME_RINVSRCADDRSUBUNIT: 0x0105, + ESME_RINVDSTADDRSUBUNIT: 0x0106, + ESME_RINVBCASTFREQINT: 0x0107, + ESME_RINVBCASTALIAS_NAME: 0x0108, + ESME_RINVBCASTAREAFMT: 0x0109, + ESME_RINVNUMBCAST_AREAS: 0x010A, + ESME_RINVBCASTCNTTYPE: 0x010B, + ESME_RINVBCASTMSGCLASS: 0x010C, + ESME_RBCASTFAIL: 0x010D, + ESME_RBCASTQUERYFAIL: 0x010E, + ESME_RBCASTCANCELFAIL: 0x010F, + ESME_RINVBCAST_REP: 0x0110, + ESME_RINVBCASTSRVGRP: 0x0111, + ESME_RINVBCASTCHANIND: 0x0112, +} as const; + +export type ErrorName = keyof typeof errors; + +export const errorsById: Record = {}; + +for (const [name, code] of Object.entries(errors)) { + errorsById[code] = name; +} + +export function isErrorName(value: unknown): value is ErrorName { + return typeof value === 'string' && Object.hasOwn(errors, value); +} + +export function errorNameById(id: number): ErrorName | undefined { + const name = errorsById[id]; + + return isErrorName(name) ? name : undefined; +} diff --git a/src/defs/types.ts b/src/defs/types.ts new file mode 100644 index 0000000..798b775 --- /dev/null +++ b/src/defs/types.ts @@ -0,0 +1,391 @@ +import type { Result, VoidResult } from '../result.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; + +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; +}; + +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`, + ); + } + + return undefined; +} + +function asString(value: number | string): string { + return typeof value === 'number' ? value.toString() : value; +} + +function readCstring(buffer: Buffer, offset: number): Result<{ value: string }> { + let length = 0; + + while (buffer[offset + length]) { + length++; + + if (offset + length >= buffer.length) { + return { err: new Error('Unterminated C-Octet String') }; + } + } + + return { 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); + + if (err) return { err }; + + buffer.write(str, offset, 'ascii'); + buffer[offset + str.length] = 0; + + return {}; +} + +function sizeCstring(value: number | string): number { + return asString(value).length + 1; +} + +export const int8: WireType = { + default: 0, + read(buffer, offset) { + const err = outOfRange(buffer, offset, 1); + + 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 = { + 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 { value: buffer.toString('ascii', offset + 1, offset + 1 + length) }; + }, + size(value) { + return asString(value).length + 1; + }, + write(value, buffer, offset) { + const str = asString(value); + const err = outOfRange(buffer, offset, str.length + 1); + + if (err) return { err }; + + buffer.writeUInt8(str.length, offset); + buffer.write(str, offset + 1, 'ascii'); + + return {}; + }, +}; + +/** C-Octet String: NULL-terminated. */ +export const cstring: WireType = { + default: '', + read: readCstring, + size: sizeCstring, + write: writeCstring, +}; + +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) }; + }, + // 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'); + + return buf[buf.length - 1] === 0x00 ? buf.length - 1 : buf.length; + }, + write(value, buf, offset) { + const source = Buffer.isBuffer(value) ? value : Buffer.from(asString(value), 'ascii'); + const err = outOfRange(buf, offset, source.length); + + if (err) return { err }; + + source.copy(buf, offset); + + return {}; + }, +}; + +export const dest_address_array: WireType = { + default: [], + read(buf, offset) { + const countErr = outOfRange(buf, offset, 1); + + if (countErr) return { err: countErr }; + + const result: 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 { err, value } = readCstring(buf, offset); + + if (err) return { err }; + + offset += sizeCstring(value); + result.push({ dest_addr_npi, dest_addr_ton, destination_addr: value }); + } else { + const { err, value } = readCstring(buf, offset); + + if (err) return { err }; + + offset += sizeCstring(value); + result.push({ dl_name: value }); + } + } + + return { value: result }; + }, + size(value) { + let size = 1; + + for (const dest of value) { + size += 'dl_name' in dest + ? sizeCstring(dest.dl_name) + 1 + : sizeCstring(dest.destination_addr) + 3; + } + + return size; + }, + write(value, buf, offset) { + const err = outOfRange(buf, offset, dest_address_array.size(value)); + + if (err) return { err }; + + buf.writeUInt8(value.length, offset++); + + for (const dest of value) { + if ('dl_name' in dest) { + buf.writeUInt8(2, offset++); + writeCstring(dest.dl_name, buf, offset); + offset += sizeCstring(dest.dl_name); + } else { + buf.writeUInt8(1, offset++); + buf.writeUInt8(dest.dest_addr_ton || 0, offset++); + buf.writeUInt8(dest.dest_addr_npi || 0, offset++); + writeCstring(dest.destination_addr, buf, offset); + offset += sizeCstring(dest.destination_addr); + } + } + + return {}; + }, +}; + +export const unsuccess_sme_array: WireType = { + default: [], + read(buf, offset) { + const countErr = outOfRange(buf, offset, 1); + + if (countErr) return { err: countErr }; + + const result: 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 { err, value } = readCstring(buf, offset); + + if (err) return { err }; + + offset += sizeCstring(value); + + const statusErr = outOfRange(buf, offset, 4); + + if (statusErr) return { err: statusErr }; + + result.push({ + dest_addr_npi, + dest_addr_ton, + destination_addr: value, + error_status_code: buf.readUInt32BE(offset), + }); + offset += 4; + } + + return { value: result }; + }, + size(value) { + let size = 1; + + for (const sme of value) { + size += sizeCstring(sme.destination_addr) + 6; + } + + return size; + }, + write(value, buf, offset) { + const err = outOfRange(buf, offset, unsuccess_sme_array.size(value)); + + if (err) return { err }; + + buf.writeUInt8(value.length, offset++); + + for (const sme of value) { + buf.writeUInt8(sme.dest_addr_ton || 0, offset++); + buf.writeUInt8(sme.dest_addr_npi || 0, offset++); + writeCstring(sme.destination_addr, buf, offset); + offset += sizeCstring(sme.destination_addr); + buf.writeUInt32BE(sme.error_status_code, offset); + offset += 4; + } + + return {}; + }, +}; + +/** TLV variants are length-prefixed by the TLV header, so they carry no length of their own. */ +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, + cstring, + int8, + int16, + int32, + string: { + default: '', + read(buf: Buffer, offset: number, length = 0): Result<{ value: string }> { + const err = outOfRange(buf, offset, length); + + return err ? { err } : { value: buf.toString('ascii', offset, offset + length) }; + }, + size(value: number | string): number { + return asString(value).length; + }, + write(value: number | string, buf: Buffer, offset: number): VoidResult { + const str = asString(value); + const err = outOfRange(buf, offset, str.length); + + if (err) return { err }; + + buf.write(str, offset, 'ascii'); + + return {}; + }, + } satisfies WireType, +}; + +export const types = { + buffer, + cstring, + dest_address_array, + int8, + int16, + int32, + string, + tlv, + unsuccess_sme_array, +}; diff --git a/test/encodings.test.ts b/test/encodings.test.ts new file mode 100644 index 0000000..190a61f --- /dev/null +++ b/test/encodings.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import { detect, encodingByDataCoding, encodings } from '../src/defs/encodings.ts'; + +describe('ASCII (GSM 03.38)', () => { + const samples: [string, number[]][] = [ + ['@£$¥', [0, 1, 2, 3]], + [' 1a=', [0x20, 0x31, 0x61, 0x3D]], + ['~^€', [0x1B, 0x3D, 0x1B, 0x14, 0x1B, 0x65]], + ]; + + test('matches strings encodable in the GSM 03.38 charset', () => { + assert.ok(encodings.ASCII.match('')); + assert.ok(encodings.ASCII.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); + assert.ok(encodings.ASCII.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); + assert.ok(encodings.ASCII.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); + assert.ok(encodings.ASCII.match('\f^{}\\[~]|€')); + }); + + test('rejects strings outside the GSM 03.38 charset', () => { + assert.ok(!encodings.ASCII.match('`')); + assert.ok(!encodings.ASCII.match('ÁáçÚUÓO')); + assert.ok(!encodings.ASCII.match('تست')); + }); + + test('round-trips the sample strings', () => { + for (const [str, bytes] of samples) { + assert.deepEqual(encodings.ASCII.encode(str), Buffer.from(bytes)); + assert.equal(encodings.ASCII.decode(Buffer.from(bytes)), str); + } + }); +}); + +describe('LATIN1', () => { + const samples: [string, number[]][] = [ + ['@$`Á', [0x40, 0x24, 0x60, 0xC1]], + ['áçÚ', [0xE1, 0xE7, 0xDA]], + ['UÓO', [0x55, 0xD3, 0x4F]], + ]; + + test('never matches, so it is never auto-selected for new messages', () => { + assert.ok(!encodings.LATIN1.match('`ÁáçÚUÓO')); + assert.ok(!encodings.LATIN1.match('تست')); + assert.ok(!encodings.LATIN1.match('۱۲۳۴۵۶۷۸۹۰')); + }); + + test('round-trips the sample strings', () => { + for (const [str, bytes] of samples) { + assert.deepEqual(encodings.LATIN1.encode(str), Buffer.from(bytes)); + assert.equal(encodings.LATIN1.decode(Buffer.from(bytes)), str); + } + }); +}); + +describe('UCS2', () => { + const samples: [string, number[]][] = [ + [' 1a', [0x00, 0x20, 0x00, 0x31, 0x00, 0x61]], + ['۱۲۳', [0x06, 0xF1, 0x06, 0xF2, 0x06, 0xF3]], + ]; + + test('always matches', () => { + assert.ok(encodings.UCS2.match('')); + assert.ok(encodings.UCS2.match('`ÁáçÚUÓO')); + assert.ok(encodings.UCS2.match('تست')); + }); + + test('round-trips the sample strings', () => { + for (const [str, bytes] of samples) { + assert.deepEqual(encodings.UCS2.encode(str), Buffer.from(bytes)); + assert.equal(encodings.UCS2.decode(Buffer.from(bytes)), str); + } + }); + + test('decoding does not mutate the caller\'s buffer', () => { + const buffer = Buffer.from([0x00, 0x20]); + + encodings.UCS2.decode(buffer); + + assert.deepEqual(buffer, Buffer.from([0x00, 0x20])); + }); +}); + +describe('detect()', () => { + test('picks the narrowest encoding that fits the string', () => { + assert.equal(detect(''), 'ASCII'); + assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'ASCII'); + assert.equal(detect('`ÁáçÚUÓO'), 'UCS2'); + assert.equal(detect('«©®µ¶±»'), 'UCS2'); + assert.equal(detect('ʹʺʻʼʽ`'), 'UCS2'); + assert.equal(detect('تست'), 'UCS2'); + assert.equal(detect('۱۲۳۴۵۶۷۸۹۰'), 'UCS2'); + }); +}); + +describe('encodingByDataCoding()', () => { + test('resolves the flat SMPP data_coding table', () => { + assert.equal(encodingByDataCoding(0x00), 'ASCII'); + assert.equal(encodingByDataCoding(0x01), 'ASCII'); + assert.equal(encodingByDataCoding(0x08), 'UCS2'); + }); + + // 0.4.0 resolved 0x03 to the alias ISO_8859_1, which has no decoder, and silently fell back to + // ASCII — every Latin-1 message came out corrupted. + test('resolves 0x03 to LATIN1 rather than falling back to ASCII', () => { + assert.equal(encodingByDataCoding(0x03), 'LATIN1'); + }); + + test('reads the alphabet bits when a message class is present', () => { + assert.equal(encodingByDataCoding(0x10), 'ASCII'); + assert.equal(encodingByDataCoding(0x18), 'UCS2'); + assert.equal(encodingByDataCoding(0xF0), 'ASCII'); + }); + + test('falls back to ASCII for alphabets it has no codec for', () => { + assert.equal(encodingByDataCoding(0x05), 'ASCII'); + assert.equal(encodingByDataCoding(0x0E), 'ASCII'); + }); +}); diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..ede7381 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts'; +import { types } from '../src/defs/types.ts'; + +describe('integers', () => { + test('int8 reads, sizes and writes one octet', () => { + 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.write(0x65, target, 0), {}); + assert.deepEqual(target, Buffer.from([0x65])); + }); + + test('int16 reads, sizes 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); + types.int16.write(0x0565, target, 0); + assert.deepEqual(target, Buffer.from([0x05, 0x65])); + }); + + test('int32 reads, sizes 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); + types.int32.write(0x10024045, target, 0); + assert.deepEqual(target, Buffer.from([0x10, 0x02, 0x40, 0x45])); + }); +}); + +describe('string (Octet String)', () => { + const expected = 'abcd1234'; + 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 }); + }); + + test('sizes as the string plus its length octet', () => { + assert.equal(types.string.size(expected), 9); + }); + + test('writes a length-prefixed string', () => { + const target = Buffer.alloc(9); + + types.string.write(expected, target, 0); + + assert.deepEqual(target, encoded); + }); +}); + +describe('cstring (C-Octet String)', () => { + const expected = 'abcd1234'; + 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 }); + }); + + test('sizes as the string plus its NULL terminator', () => { + assert.equal(types.cstring.size(expected), 9); + }); + + test('writes a NULL-terminated string', () => { + const target = Buffer.alloc(9); + + types.cstring.write(expected, target, 0); + + assert.deepEqual(target, encoded); + }); + + test('coerces a numeric value to its decimal string', () => { + const target = Buffer.alloc(4); + + types.cstring.write(123, target, 0); + + assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00])); + assert.equal(types.cstring.size(123), 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); + }); +}); + +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 }); + }); + + test('sizes a binary field in octets', () => { + assert.equal(types.buffer.size(expected), 8); + }); + + test('writes a binary field', () => { + const target = Buffer.alloc(8); + + types.buffer.write(expected, target, 0); + + assert.deepEqual(target, expected); + }); +}); + +describe('dest_address_array', () => { + const encoded = Buffer.from([ + 0x02, + 0x01, 0x01, 0x02, 0x31, 0x32, 0x33, 0x00, + 0x02, 0x61, 0x62, 0x63, 0x00, + ]); + const expected: DestAddress[] = [ + { dest_addr_npi: 2, dest_addr_ton: 1, destination_addr: '123' }, + { dl_name: 'abc' }, + ]; + + test('reads every dest_address structure', () => { + assert.deepEqual(types.dest_address_array.read(encoded, 0), { value: expected }); + }); + + test('sizes every dest_address structure', () => { + assert.equal(types.dest_address_array.size(expected), 13); + }); + + test('writes every dest_address structure', () => { + const target = Buffer.alloc(13); + + types.dest_address_array.write(expected, target, 0); + + assert.deepEqual(target, encoded); + }); +}); + +describe('unsuccess_sme_array', () => { + const encoded = Buffer.from([ + 0x02, + 0x03, 0x04, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x07, + 0x05, 0x06, 0x31, 0x32, 0x33, 0x00, 0x10, 0x00, 0x00, 0x08, + ]); + const expected: UnsuccessSme[] = [ + { dest_addr_npi: 4, dest_addr_ton: 3, destination_addr: 'abc', error_status_code: 0x00000007 }, + { dest_addr_npi: 6, dest_addr_ton: 5, destination_addr: '123', error_status_code: 0x10000008 }, + ]; + + test('reads every unsuccess_sme structure', () => { + assert.deepEqual(types.unsuccess_sme_array.read(encoded, 0), { value: expected }); + }); + + test('sizes every unsuccess_sme structure', () => { + assert.equal(types.unsuccess_sme_array.size(expected), 21); + }); + + test('writes every unsuccess_sme structure', () => { + const target = Buffer.alloc(21); + + types.unsuccess_sme_array.write(expected, target, 0); + + assert.deepEqual(target, encoded); + }); +}); + +describe('bounds checking', () => { + // 0.4.0 let a short or malformed PDU throw straight out of the codec. + test('reading past the end returns an error instead of throwing', () => { + 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); + }); + + test('writing past the end returns an error instead of throwing', () => { + assert.ok(types.int32.write(1, Buffer.alloc(2), 0).err instanceof Error); + assert.ok(types.cstring.write('abcd', Buffer.alloc(2), 0).err instanceof Error); + }); +}); diff --git a/todo.md b/todo.md index 9f42706..86872a7 100644 --- a/todo.md +++ b/todo.md @@ -118,9 +118,7 @@ smpp.on('session', session => { ### 1. Definition tables — `src/defs/` - [x] `constants.ts` — `consts` + `constsById`. -- [ ] `errors.ts` — all `ESME_*` codes plus `errorsById`. Port from 0.4.0 `lib/defs.js`; note that - 0.4.0 has a typo, `ESME_RINVBCASTCHANIND = 0x011` (three digits), which should be `0x0111`. - Verify every code against the SMPP 3.4/5.0 spec while porting. +- [x] `errors.ts` — all `ESME_*` codes plus `errorsById`. - [ ] `types.ts` — wire types `int8`, `int16`, `int32`, `string`, `cstring`, `buffer`, `dest_address_array`, `unsuccess_sme_array`, and the `tlv` variants. Each is `{ default, read(buffer, offset, length?), size(value), write(value, buffer, offset) }`.