From 649e93cddec47d2f61ac34dae1f37eef9660a083 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 25 Aug 2026 16:28:59 +0200 Subject: [PATCH] Add the PDU codec with bounds-checked parsing and typed encoding --- src/defs/commands.ts | 14 +- src/defs/types.ts | 27 +++- src/pdu.ts | 322 ++++++++++++++++++++++++++++++++++++++++++ test/commands.test.ts | 4 - test/pdu.test.ts | 313 ++++++++++++++++++++++++++++++++++++++++ todo.md | 38 +++-- 6 files changed, 687 insertions(+), 31 deletions(-) create mode 100644 src/pdu.ts create mode 100644 test/pdu.test.ts diff --git a/src/defs/commands.ts b/src/defs/commands.ts index ad345ff..cd37652 100644 --- a/src/defs/commands.ts +++ b/src/defs/commands.ts @@ -1,8 +1,7 @@ -import type { ParamValue, WireType } from './types.ts'; +import type { 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; @@ -34,11 +33,11 @@ const specs = { esme_addr: cstring, }, }, - bind_receiver: { defaults: { interface_version: 0x50 }, id: 0x00000001, params: bindParams }, + bind_receiver: { 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: { 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: { id: 0x00000009, params: bindParams }, bind_transceiver_resp: { id: 0x80000009, params: { system_id: cstring } }, broadcast_sm: { id: 0x00000111, @@ -191,6 +190,7 @@ const specs = { replace_if_present_flag: int8, data_coding: int8, sm_default_msg_id: int8, + sm_length: int8, short_message: buffer, }, }, @@ -238,7 +238,9 @@ export type PduParams = { /** 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 + ? V extends string ? number | string + : V extends Buffer ? Buffer | string + : V : never; }; diff --git a/src/defs/types.ts b/src/defs/types.ts index e7b6528..4a2f724 100644 --- a/src/defs/types.ts +++ b/src/defs/types.ts @@ -415,7 +415,32 @@ export const unsuccess_sme_array: WireType = { /** TLV variants carry no length of their own; the TLV header supplies it. */ export const tlv = { buffer, - cstring, + // 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('ascii', offset, end) }; + }, + size(value: ParamValue) { + const { err, text } = wantText(value); + + return err ? { err } : { size: text.length + 1 }; + }, + write(value: ParamValue, buf: Buffer, offset: number) { + const { err, text } = wantText(value); + + return err ? { err } : writeCstring(text, buf, offset); + }, + } satisfies WireType, int8, int16, int32, diff --git a/src/pdu.ts b/src/pdu.ts new file mode 100644 index 0000000..29e49da --- /dev/null +++ b/src/pdu.ts @@ -0,0 +1,322 @@ +import type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; +import type { ErrorName } from './defs/errors.ts'; +import type { ParamValue } from './defs/types.ts'; +import type { Result } from './result.ts'; +import type { Tlv } from './defs/tlvs.ts'; +import { cmds, commandNameById, isCommandName } from './defs/commands.ts'; +import { consts } from './defs/constants.ts'; +import { decodeMessage, encodeMessage } from './message.ts'; +import { detect, encodingByDataCoding } from './defs/encodings.ts'; +import { errorNameById, errors, isErrorName } from './defs/errors.ts'; +import { tlvDefault, tlvsById } from './defs/tlvs.ts'; + +/** Sequence numbers are a 31-bit field; 0x7fffffff is reserved. */ +export const maxSeqNr = 2147483646; + +/** A hostile peer must not be able to make us allocate arbitrarily. */ +export const maxPduLength = 1024 * 1024; + +export type TlvInput = { + tagId: number; + tagName?: string | undefined; + tagValue: ParamValue; +}; + +export type PduObjectInput = { + cmdName: C; + cmdStatus?: ErrorName; + params?: PduParamsInput; + seqNr?: number; + tlvs?: Record | undefined; +}; + +/** + * A parsed PDU. `params` is loosely typed because the command is only known at runtime — narrow it + * with `isCommand()` to get the parameters of a specific command. + */ +export type PduObject = { + cmdId: number; + cmdLength: number; + cmdName: CommandName; + cmdStatus: ErrorName | undefined; + cmdStatusId: number; + params: Record; + seqNr: number; + tlvs: Record; +}; + +export function isResp(pduObj: Pick): boolean { + return pduObj.cmdId >= 0x80000000; +} + +/** + * Narrows a parsed PDU to one command, giving its parameters their real types. The parser fills + * every parameter the command declares with the type that command declares, which is what makes + * this sound. + */ +export function isCommand( + pduObj: PduObject, + cmdName: C, +): pduObj is PduObject & { cmdName: C; params: PduParams } { + return pduObj.cmdName === cmdName; +} + +function numberOr(value: ParamValue | undefined, fallback: number): number { + return typeof value === 'number' ? value : fallback; +} + +function buildPdu( + cmdName: CommandName, + cmdStatus: ErrorName, + seqNr: number, + params: Record, + tlvs: Record | undefined, +): Result<{ buffer: Buffer }> { + const definition = cmds[cmdName]; + + if (!definition) { + return { err: new Error(`Invalid cmdName: ${JSON.stringify(cmdName)}`) }; + } + + if (!isErrorName(cmdStatus)) { + return { err: new Error(`Invalid cmdStatus: ${JSON.stringify(cmdStatus)}`) }; + } + + if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxSeqNr) { + return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) }; + } + + const resolved = { ...params }; + const message = resolved.short_message; + + // A string short_message is encoded here, which also settles data_coding and sm_length. + if (typeof message === 'string') { + const dataCoding = resolved.data_coding; + const encoding = typeof dataCoding === 'number' + ? encodingByDataCoding(dataCoding) + : detect(message); + const encoded = encodeMessage(message, encoding); + + resolved.short_message = encoded.buffer; + resolved.sm_length = encoded.buffer.length; + + if (typeof dataCoding !== 'number') { + resolved.data_coding = consts.ENCODING[encoded.encoding]; + } + } + + const chunks: Buffer[] = []; + + for (const [name, type] of Object.entries(definition.params ?? {})) { + const value = resolved[name] ?? type.default; + const sized = type.size(value); + + if (sized.err) { + return { err: new Error(`Parameter "${name}" of "${cmdName}": ${sized.err.message}`) }; + } + + const chunk = Buffer.alloc(sized.size); + const written = type.write(value, chunk, 0); + + if (written.err) { + return { err: new Error(`Parameter "${name}" of "${cmdName}": ${written.err.message}`) }; + } + + chunks.push(chunk); + } + + for (const [name, tlv] of Object.entries(tlvs ?? {})) { + const type = tlvsById[tlv.tagId]?.type ?? tlvDefault; + const sized = type.size(tlv.tagValue); + + if (sized.err) { + return { err: new Error(`TLV "${name}": ${sized.err.message}`) }; + } + + const chunk = Buffer.alloc(sized.size + 4); + + chunk.writeUInt16BE(tlv.tagId, 0); + chunk.writeUInt16BE(sized.size, 2); + + const written = type.write(tlv.tagValue, chunk, 4); + + if (written.err) { + return { err: new Error(`TLV "${name}": ${written.err.message}`) }; + } + + chunks.push(chunk); + } + + const body = Buffer.concat(chunks); + const header = Buffer.alloc(16); + + header.writeUInt32BE(body.length + 16, 0); + header.writeUInt32BE(definition.id, 4); + header.writeUInt32BE(errors[cmdStatus], 8); + header.writeUInt32BE(seqNr, 12); + + return { buffer: Buffer.concat([header, body]) }; +} + +export function objToPdu(obj: PduObjectInput): Result<{ buffer: Buffer }> { + return buildPdu( + obj.cmdName, + obj.cmdStatus ?? 'ESME_ROK', + obj.seqNr ?? 1, + { ...obj.params }, + obj.tlvs, + ); +} + +function parseTlvs( + pdu: Buffer, + start: number, + cmdLength: number, +): Result<{ offset: number; tlvs: Record }> { + const tlvs: Record = {}; + let offset = start; + + while (offset + 4 <= cmdLength) { + const tagId = pdu.readUInt16BE(offset); + const tagLength = pdu.readUInt16BE(offset + 2); + + if (offset + 4 + tagLength > cmdLength) { + return { err: new Error(`TLV ${String(tagId)} runs past the end of the PDU`) }; + } + + const definition = tlvsById[tagId]; + const read = (definition?.type ?? tlvDefault).read(pdu, offset + 4, tagLength); + + if (read.err) return { err: read.err }; + + tlvs[definition?.tag ?? tagId.toString()] = { + tagId, + tagName: definition?.tag, + tagValue: Buffer.isBuffer(read.value) ? read.value.toString('hex') : read.value, + }; + + offset += 4 + tagLength; + } + + return { offset, tlvs }; +} + +function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolean; pduObj: PduObject }> { + const cmdLength = pdu.readUInt32BE(0); + const cmdId = pdu.readUInt32BE(4); + const cmdName = commandNameById(cmdId); + + if (!cmdName) { + return { err: new Error(`Unknown PDU command id: ${String(cmdId)}`) }; + } + + const cmdStatusId = pdu.readUInt32BE(8); + const seqNr = pdu.readUInt32BE(12); + + if (seqNr > maxSeqNr) { + return { err: new Error(`Invalid seqNr, exceeds ${String(maxSeqNr)}: ${String(seqNr)}`) }; + } + + const params: Record = {}; + let offset = 16; + + for (const [name, type] of Object.entries(cmds[cmdName]?.params ?? {})) { + const read = type.read(pdu, offset, numberOr(params.sm_length, 0)); + + if (read.err) { + return { err: new Error(`Parameter "${name}" of "${cmdName}": ${read.err.message}`) }; + } + + params[name] = read.value; + offset += read.bytesRead; + + if (name === 'short_message' && trailingNull) offset++; + } + + const parsed = parseTlvs(pdu, offset, cmdLength); + + if (parsed.err) return { err: parsed.err }; + + const message = params.short_message; + const esmClass = numberOr(params.esm_class, 0); + + // A message carrying a UDH stays a buffer; the session needs the header intact to reassemble. + if (Buffer.isBuffer(message) && (esmClass & consts.ESM_CLASS.UDH_INDICATOR) !== consts.ESM_CLASS.UDH_INDICATOR) { + params.short_message = decodeMessage(message, numberOr(params.data_coding, 0)).message; + } + + return { + aligned: parsed.offset === cmdLength, + pduObj: { + cmdId, + cmdLength, + cmdName, + cmdStatus: errorNameById(cmdStatusId), + cmdStatusId, + params, + seqNr, + tlvs: parsed.tlvs, + }, + }; +} + +export function pduToObj(pdu: Buffer): Result<{ pduObj: PduObject }> { + if (pdu.length < 16) { + return { err: new Error(`PDU is too short, minimum is 16 octets, got ${String(pdu.length)}`) }; + } + + const cmdLength = pdu.readUInt32BE(0); + + if (cmdLength < 16 || cmdLength > maxPduLength) { + return { err: new Error(`Refusing a cmd_length of ${String(cmdLength)}`) }; + } + + if (cmdLength > pdu.length) { + return { err: new Error(`cmd_length ${String(cmdLength)} exceeds the ${String(pdu.length)} octets given`) }; + } + + const plain = parseOnce(pdu, false); + + if (!plain.err && plain.aligned) return { pduObj: plain.pduObj }; + + // Some peers append a NULL octet after short_message; allow for it before giving up. + const padded = parseOnce(pdu, true); + + if (!padded.err && padded.aligned) return { pduObj: padded.pduObj }; + if (!plain.err) return { pduObj: plain.pduObj }; + if (!padded.err) return { pduObj: padded.pduObj }; + + return { err: plain.err }; +} + +export function pduReturn( + pdu: Buffer | PduObject, + status: ErrorName = 'ESME_ROK', + params: Record = {}, + tlvs?: Record, +): Result<{ buffer: Buffer }> { + if (Buffer.isBuffer(pdu)) { + const parsed = pduToObj(pdu); + + return parsed.err ? { err: parsed.err } : pduReturn(parsed.pduObj, status, params, tlvs); + } + + const respName = `${pdu.cmdName}_resp`; + + if (!isCommandName(respName)) { + return { err: new Error(`"${pdu.cmdName}" has no response command`) }; + } + + const respParams: Record = { ...params }; + + // Fields the response shares with the request are echoed back unless the caller overrode them. + for (const name of Object.keys(cmds[respName]?.params ?? {})) { + const value = pdu.params[name]; + + if (respParams[name] === undefined && value !== undefined) { + respParams[name] = value; + } + } + + return buildPdu(respName, status, pdu.seqNr, respParams, tlvs); +} diff --git a/test/commands.test.ts b/test/commands.test.ts index 94b738a..6f9e2a7 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -15,10 +15,6 @@ describe('command table', () => { 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 ?? {}), [ diff --git a/test/pdu.test.ts b/test/pdu.test.ts new file mode 100644 index 0000000..d73df4d --- /dev/null +++ b/test/pdu.test.ts @@ -0,0 +1,313 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; + +function encode(...args: Parameters): Buffer { + const { buffer, err } = objToPdu(...args); + + assert.equal(err, undefined); + assert.ok(buffer); + + return buffer; +} + +function decode(pdu: Buffer) { + const { err, pduObj } = pduToObj(pdu); + + assert.equal(err, undefined); + assert.ok(pduObj); + + return pduObj; +} + +describe('header', () => { + test('writes command length, id, status and sequence number', () => { + const pdu = encode({ cmdName: 'bind_transceiver_resp', cmdStatus: 'ESME_RALYBND', seqNr: 1 }); + + assert.equal(pdu.readUInt32BE(0), 17); + assert.equal(pdu.readUInt32BE(4).toString(16), '80000009'); + assert.equal(pdu.readUInt32BE(8), 5); + assert.equal(pdu.readUInt32BE(12), 1); + }); + + test('round-trips back to the same object', () => { + const pduObj = decode(encode({ + cmdName: 'bind_transceiver_resp', + cmdStatus: 'ESME_RALYBND', + seqNr: 1, + })); + + assert.equal(pduObj.cmdId.toString(16), '80000009'); + assert.equal(pduObj.cmdStatus, 'ESME_RALYBND'); + assert.equal(pduObj.seqNr, 1); + assert.ok(isResp(pduObj)); + }); + + test('rejects an unknown command and an out-of-range sequence number', () => { + assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 2147483647 }).err instanceof Error); + }); +}); + +describe('parsing real PDUs', () => { + test('parses a bind_transmitter captured from an SMSC', () => { + const pduObj = decode(Buffer.from( + '0000002F000000020000000000000001534D50503354455354007365637265743038005355424D4954310000010100', + 'hex', + )); + + assert.equal(pduObj.cmdId, 2); + assert.equal(pduObj.cmdStatus, 'ESME_ROK'); + assert.equal(pduObj.cmdName, 'bind_transmitter'); + assert.equal(pduObj.params.system_id, 'SMPP3TEST'); + assert.equal(pduObj.params.interface_version, 0); + }); + + test('reads a submit_sm with a trailing NULL octet after short_message', () => { + const pduObj = decode(Buffer.from( + '0000003c0000000400000000000000020001003436373031313333313131000101343637303937373133333700000000000000000100047465737400', + 'hex', + )); + + assert.equal(pduObj.params.short_message, 'test'); + assert.equal(pduObj.cmdLength, 60); + }); + + test('reads a submit_sm without one', () => { + const pduObj = decode(Buffer.from( + '0000003b00000004000000000000000200010034363730313133333131310001013436373039373731333337000000000000000001000474657374', + 'hex', + )); + + assert.equal(pduObj.params.short_message, 'test'); + assert.equal(pduObj.cmdLength, 59); + }); +}); + +describe('encoding submit_sm', () => { + test('produces the same bytes as 0.4.0 for a GSM message', () => { + const pdu = encode({ + cmdName: 'submit_sm', + cmdStatus: 'ESME_ROK', + params: { + destination_addr: '46709771337', + short_message: 'Hello world', + source_addr: '46701113311', + }, + seqNr: 12, + }); + + assert.equal( + pdu.toString('hex'), + '0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000001000b48656c6c6f20776f726c64', + ); + }); + + test('produces the same bytes as 0.4.0 for a UCS2 message', () => { + const pdu = encode({ + cmdName: 'submit_sm', + cmdStatus: 'ESME_ROK', + params: { + destination_addr: '46709771337', + short_message: 'Hello«»world', + source_addr: '46701113311', + }, + seqNr: 12, + }); + + assert.equal( + pdu.toString('hex'), + '0000004f00000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000008001800480065006c006c006f00ab00bb0077006f0072006c0064', + ); + }); + + test('keeps a UDH-carrying short_message as a buffer', () => { + const message = Buffer.concat([ + Buffer.from('050003010101', 'hex'), + Buffer.from('hej världen'), + ]); + const pduObj = decode(encode({ + cmdName: 'submit_sm', + params: { + data_coding: 0x08, + destination_addr: '46709771337', + esm_class: 0x40, + short_message: message, + sm_length: message.length, + source_addr: '46701113311', + }, + seqNr: 12, + })); + + assert.ok(Buffer.isBuffer(pduObj.params.short_message)); + assert.equal(pduObj.params.short_message.toString('hex'), '05000301010168656a2076c3a4726c64656e'); + }); + + test('accepts a number for a C-string parameter', () => { + const pduObj = decode(encode({ + cmdName: 'submit_sm_resp', + params: { message_id: 450 }, + seqNr: 2, + })); + + assert.equal(pduObj.params.message_id, '450'); + }); + + // 0.4.0 allocated one octet short whenever the message ended in 0x00 while still reporting the + // full sm_length, so the PDU went out corrupt. + test('encodes a UCS2 message ending in a zero low byte', () => { + const pduObj = decode(encode({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', + short_message: 'hej 一', + source_addr: '46701113311', + }, + seqNr: 3, + })); + + assert.equal(pduObj.params.short_message, 'hej 一'); + assert.equal(pduObj.params.sm_length, 10); + }); +}); + +describe('TLVs', () => { + test('extracts TLVs from a delivery receipt captured from an SMSC', () => { + const pduObj = decode(Buffer.from( + '000000e9000000050000000002a82e8600010134363730393737313333370000003436373031313133333131000400000000000000007569643a313535303430363231323432313433353835207375623a30303120646c7672643a303031207375626d697420646174653a3135303430363233323420646f6e6520646174653a3135303430363233323420737461743a44454c49565244206572723a3030303020746578743a202062616666042300030300000427000102001e001331353530343036323132343231343335383500141800040000076c145400040000000114160006323430303800', + 'hex', + )); + + assert.equal(pduObj.cmdId.toString(16), '5'); + assert.equal(pduObj.cmdStatus, 'ESME_ROK'); + assert.equal(pduObj.seqNr, 44576390); + assert.equal(pduObj.params.destination_addr, '46701113311'); + assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, '155040621242143585'); + }); + + test('round-trips known and unknown TLVs', () => { + const pduObj = decode(encode({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + esm_class: 4, + short_message: 'random stuff', + source_addr: '46701113311', + }, + seqNr: 393, + tlvs: { + 5142: { tagId: 5142, tagName: 'Nils', tagValue: Buffer.from('blajfoo', 'ascii') }, + receipted_message_id: { + tagId: 0x001E, + tagName: 'receipted_message_id', + tagValue: '293f293', + }, + }, + })); + + assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, '293f293'); + assert.equal(pduObj.tlvs['5142']?.tagName, undefined); + + const unknown = pduObj.tlvs['5142']?.tagValue; + + assert.equal(typeof unknown, 'string'); + assert.equal(Buffer.from(typeof unknown === 'string' ? unknown : '', 'hex').toString('ascii'), 'blajfoo'); + }); + + test('round-trips a receipt with message_state and receipted_message_id', () => { + const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx'; + const pduObj = decode(encode({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + esm_class: 4, + short_message: receipt, + source_addr: '46701113311', + }, + seqNr: 323, + tlvs: { + message_state: { tagId: 1063, tagName: 'message_state', tagValue: 2 }, + receipted_message_id: { tagId: 30, tagName: 'receipted_message_id', tagValue: 450 }, + }, + })); + + assert.equal(pduObj.params.short_message, receipt); + assert.equal(pduObj.cmdName, 'deliver_sm'); + assert.equal(pduObj.tlvs.message_state?.tagValue, 2); + assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, '450'); + assert.equal(pduObj.seqNr, 323); + }); +}); + +describe('pduReturn()', () => { + test('builds the matching response and echoes shared parameters', () => { + const request = Buffer.from( + '0000002f000000020000000000000001534d50503354455354007365637265743038005355424d4954310000010100', + 'hex', + ); + const { buffer, err } = pduReturn(request); + + assert.equal(err, undefined); + assert.ok(buffer); + + const pduObj = decode(buffer); + + assert.equal(pduObj.cmdId, 2147483650); + assert.equal(pduObj.cmdStatus, 'ESME_ROK'); + assert.equal(pduObj.cmdName, 'bind_transmitter_resp'); + assert.equal(pduObj.params.system_id, 'SMPP3TEST'); + }); + + test('lets a caller override a parameter and set a status', () => { + const request = decode(encode({ + cmdName: 'submit_sm', + params: { destination_addr: '46709771337', short_message: 'hi', source_addr: 'foo' }, + seqNr: 9, + })); + const { buffer, err } = pduReturn(request, 'ESME_RINVDSTADR', { message_id: 'abc123' }); + + assert.equal(err, undefined); + assert.ok(buffer); + + const pduObj = decode(buffer); + + assert.equal(pduObj.cmdName, 'submit_sm_resp'); + assert.equal(pduObj.cmdStatus, 'ESME_RINVDSTADR'); + assert.equal(pduObj.params.message_id, 'abc123'); + assert.equal(pduObj.seqNr, 9); + }); + + test('refuses a command that has no response', () => { + const request = decode(encode({ cmdName: 'submit_sm_resp', seqNr: 1 })); + + assert.ok(pduReturn(request).err instanceof Error); + }); +}); + +describe('malformed input', () => { + // 0.4.0 threw out of the codec for all of these. + test('reports rather than throws', () => { + assert.ok(pduToObj(Buffer.alloc(4)).err instanceof Error); + assert.ok(pduToObj(Buffer.from('0000000f0000000400000000000000ff', 'hex')).err instanceof Error); + assert.ok(pduToObj(Buffer.from('000000ff0000000400000000000000ff', 'hex')).err instanceof Error); + assert.ok(pduToObj(Buffer.from('000000100badf00d0000000000000001', 'hex')).err instanceof Error); + }); + + test('refuses an absurd command length instead of allocating for it', () => { + assert.ok(pduToObj(Buffer.from('ffffffff0000000400000000000000ff', 'hex')).err instanceof Error); + }); +}); + +describe('isCommand()', () => { + test('narrows parameters to the command that was parsed', () => { + const pduObj = decode(encode({ + cmdName: 'submit_sm', + params: { destination_addr: '46709771337', short_message: 'hi', source_addr: 'foo' }, + seqNr: 1, + })); + + assert.ok(isCommand(pduObj, 'submit_sm')); + // Fails to compile if destination_addr is not known to be a string here. + assert.equal(pduObj.params.destination_addr.length, 11); + assert.ok(!isCommand(pduObj, 'deliver_sm')); + }); +}); diff --git a/todo.md b/todo.md index 316448b..5b9bece 100644 --- a/todo.md +++ b/todo.md @@ -136,29 +136,27 @@ All done, with tests in `test/encodings.test.ts`, `test/types.test.ts` and `test ### 2. Message helpers — `src/message.ts` -- [ ] `bitCount(msg, encoding?)`, `encodeMessage`, `decodeMessage`, `smppDate`, `splitMessage`. -- [ ] `smppTime.encode(value)` / `smppTime.decode(value)` — absolute and relative SMPP time formats, - replacing the dormant `filters.time`. Used by `validityPeriod` and `scheduleDeliveryTime`. -- [ ] **Fix:** segments carry 153 GSM characters or 67 UCS2 characters. 0.4.0 produces 152/66, - because it pushes `msgPart.slice(0, -1)` after accumulating a full segment. Read the - "GSM 7-bit is sent unpacked" section of AGENTS.md before touching these numbers. -- [ ] **Fix:** `smppDate` must add 1 to `getMonth()` and zero-pad correctly. -- [ ] **Fix:** `decodeMessage` must resolve the alphabet through `encodingByDataCoding` (already - written and tested) rather than scanning the alias table, which is what broke LATIN1. -- [ ] The concatenation reference counter is per session, not module-global. `splitMessage` therefore - takes the reference as an argument instead of owning a counter. +Done, tested in `test/message.test.ts`. Segments are 153 GSM / 67 UCS2 characters, `smppDate` is +UTC and one-based, `decodeMessage` goes through `encodingByDataCoding`, and `splitMessage` takes the +concatenation reference as an argument rather than owning a module-global counter. + +- [x] `bitCount`, `encodeMessage`, `decodeMessage`, `smppDate`, `splitMessage`, `smppTime`. ### 3. PDU codec — `src/pdu.ts` -- [ ] `pduToObj(buffer)` → `{ err?, pduObj? }`, `objToPdu(obj)` → `{ err?, buffer? }`, - `pduReturn(pdu, status?, params?, tlvs?)` → `{ err?, buffer? }`, `isResp(pduObj)`. -- [ ] Keep the trailing-NULL-octet retry for `short_message` that 0.4.0 has — real peers send it. - It is now an explicit decision in the parser: `types.buffer.size()` no longer silently drops a - trailing `0x00`, because that corrupted every UCS2 message ending in one (see AGENTS.md). -- [ ] Guard `cmdLength` against a maximum before allocating, so a hostile peer cannot ask for a 4 GiB - buffer. 0.4.0 has no such guard. -- [ ] Per-command typed params: `pduToObj` returns a union discriminated on `cmdName`, and - `objToPdu` narrows `params` to the named command's fields. +Done, tested in `test/pdu.test.ts` — which includes the two byte-for-byte comparisons against 0.4.0's +output and the real captured SMSC PDUs from its suite. + +- [x] `pduToObj`, `objToPdu`, `pduReturn`, `isResp`, `isCommand`. +- [x] Trailing-NULL retry kept, now an explicit second parse rather than a side effect of a length + function. +- [x] `cmdLength` guarded by `maxPduLength` (1 MiB) before anything is allocated. +- [x] `objToPdu` narrows `params` to the named command. `pduToObj` returns loosely-typed params — + the command is only known at runtime — and `isCommand(pduObj, 'submit_sm')` narrows them. + +The encoder measures each field, allocates exactly that, and writes into it, so a `size`/`write` +disagreement surfaces as an error instead of a corrupt PDU. That class of bug is what the trailing +NULL defect was. ### 4. Session — `src/session.ts`