Add the PDU codec with bounds-checked parsing and typed encoding
This commit is contained in:
@@ -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';
|
import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from './types.ts';
|
||||||
|
|
||||||
type CommandSpec = {
|
type CommandSpec = {
|
||||||
defaults?: Record<string, ParamValue>;
|
|
||||||
id: number;
|
id: number;
|
||||||
params?: Record<string, WireType>;
|
params?: Record<string, WireType>;
|
||||||
tlvMap?: Record<string, string>;
|
tlvMap?: Record<string, string>;
|
||||||
@@ -34,11 +33,11 @@ const specs = {
|
|||||||
esme_addr: cstring,
|
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_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_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 } },
|
bind_transceiver_resp: { id: 0x80000009, params: { system_id: cstring } },
|
||||||
broadcast_sm: {
|
broadcast_sm: {
|
||||||
id: 0x00000111,
|
id: 0x00000111,
|
||||||
@@ -191,6 +190,7 @@ const specs = {
|
|||||||
replace_if_present_flag: int8,
|
replace_if_present_flag: int8,
|
||||||
data_coding: int8,
|
data_coding: int8,
|
||||||
sm_default_msg_id: int8,
|
sm_default_msg_id: int8,
|
||||||
|
sm_length: int8,
|
||||||
short_message: buffer,
|
short_message: buffer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -238,7 +238,9 @@ export type PduParams<C extends CommandName = CommandName> = {
|
|||||||
/** Parameters callers supply: all optional, and numbers are accepted for the string fields. */
|
/** Parameters callers supply: all optional, and numbers are accepted for the string fields. */
|
||||||
export type PduParamsInput<C extends CommandName = CommandName> = {
|
export type PduParamsInput<C extends CommandName = CommandName> = {
|
||||||
[K in keyof ParamsSpecOf<C>]?: ParamsSpecOf<C>[K] extends WireType<infer V>
|
[K in keyof ParamsSpecOf<C>]?: ParamsSpecOf<C>[K] extends WireType<infer V>
|
||||||
? V extends string ? number | string : V
|
? V extends string ? number | string
|
||||||
|
: V extends Buffer ? Buffer | string
|
||||||
|
: V
|
||||||
: never;
|
: never;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -415,7 +415,32 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
|||||||
/** TLV variants carry no length of their own; the TLV header supplies it. */
|
/** TLV variants carry no length of their own; the TLV header supplies it. */
|
||||||
export const tlv = {
|
export const tlv = {
|
||||||
buffer,
|
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<string>,
|
||||||
int8,
|
int8,
|
||||||
int16,
|
int16,
|
||||||
int32,
|
int32,
|
||||||
|
|||||||
+322
@@ -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<C extends CommandName = CommandName> = {
|
||||||
|
cmdName: C;
|
||||||
|
cmdStatus?: ErrorName;
|
||||||
|
params?: PduParamsInput<C>;
|
||||||
|
seqNr?: number;
|
||||||
|
tlvs?: Record<string, TlvInput> | 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<string, ParamValue>;
|
||||||
|
seqNr: number;
|
||||||
|
tlvs: Record<string, Tlv>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): 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<C extends CommandName>(
|
||||||
|
pduObj: PduObject,
|
||||||
|
cmdName: C,
|
||||||
|
): pduObj is PduObject & { cmdName: C; params: PduParams<C> } {
|
||||||
|
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<string, ParamValue | undefined>,
|
||||||
|
tlvs: Record<string, TlvInput> | 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<C extends CommandName>(obj: PduObjectInput<C>): 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<string, Tlv> }> {
|
||||||
|
const tlvs: Record<string, Tlv> = {};
|
||||||
|
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<string, ParamValue> = {};
|
||||||
|
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<string, ParamValue> = {},
|
||||||
|
tlvs?: Record<string, TlvInput>,
|
||||||
|
): 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<string, ParamValue> = { ...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);
|
||||||
|
}
|
||||||
@@ -15,10 +15,6 @@ describe('command table', () => {
|
|||||||
assert.equal(Object.keys(cmds).length, 33);
|
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.
|
// Wire order, not alphabetical order — reordering these corrupts every PDU.
|
||||||
test('submit_sm parameters are in wire order', () => {
|
test('submit_sm parameters are in wire order', () => {
|
||||||
assert.deepEqual(Object.keys(cmds.submit_sm?.params ?? {}), [
|
assert.deepEqual(Object.keys(cmds.submit_sm?.params ?? {}), [
|
||||||
|
|||||||
@@ -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<typeof objToPdu>): 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`
|
### 2. Message helpers — `src/message.ts`
|
||||||
|
|
||||||
- [ ] `bitCount(msg, encoding?)`, `encodeMessage`, `decodeMessage`, `smppDate`, `splitMessage`.
|
Done, tested in `test/message.test.ts`. Segments are 153 GSM / 67 UCS2 characters, `smppDate` is
|
||||||
- [ ] `smppTime.encode(value)` / `smppTime.decode(value)` — absolute and relative SMPP time formats,
|
UTC and one-based, `decodeMessage` goes through `encodingByDataCoding`, and `splitMessage` takes the
|
||||||
replacing the dormant `filters.time`. Used by `validityPeriod` and `scheduleDeliveryTime`.
|
concatenation reference as an argument rather than owning a module-global counter.
|
||||||
- [ ] **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
|
- [x] `bitCount`, `encodeMessage`, `decodeMessage`, `smppDate`, `splitMessage`, `smppTime`.
|
||||||
"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.
|
|
||||||
|
|
||||||
### 3. PDU codec — `src/pdu.ts`
|
### 3. PDU codec — `src/pdu.ts`
|
||||||
|
|
||||||
- [ ] `pduToObj(buffer)` → `{ err?, pduObj? }`, `objToPdu(obj)` → `{ err?, buffer? }`,
|
Done, tested in `test/pdu.test.ts` — which includes the two byte-for-byte comparisons against 0.4.0's
|
||||||
`pduReturn(pdu, status?, params?, tlvs?)` → `{ err?, buffer? }`, `isResp(pduObj)`.
|
output and the real captured SMSC PDUs from its suite.
|
||||||
- [ ] 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
|
- [x] `pduToObj`, `objToPdu`, `pduReturn`, `isResp`, `isCommand`.
|
||||||
trailing `0x00`, because that corrupted every UCS2 message ending in one (see AGENTS.md).
|
- [x] Trailing-NULL retry kept, now an explicit second parse rather than a side effect of a length
|
||||||
- [ ] Guard `cmdLength` against a maximum before allocating, so a hostile peer cannot ask for a 4 GiB
|
function.
|
||||||
buffer. 0.4.0 has no such guard.
|
- [x] `cmdLength` guarded by `maxPduLength` (1 MiB) before anything is allocated.
|
||||||
- [ ] Per-command typed params: `pduToObj` returns a union discriminated on `cmdName`, and
|
- [x] `objToPdu` narrows `params` to the named command. `pduToObj` returns loosely-typed params —
|
||||||
`objToPdu` narrows `params` to the named command's fields.
|
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`
|
### 4. Session — `src/session.ts`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user