Add the PDU codec with bounds-checked parsing and typed encoding

This commit is contained in:
2026-08-25 16:28:59 +02:00
parent db5dc8cd15
commit 649e93cdde
6 changed files with 687 additions and 31 deletions
-4
View File
@@ -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 ?? {}), [
+313
View File
@@ -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'));
});
});