Refuse a single unreadable PDU instead of the whole link (#79)

* Regression tests: a PDU the codec cannot read costs that PDU, not the link

* Refuse a single unreadable PDU instead of the whole link

* smscsim interop asserts the first attempt gets every DLR

* Record the smscsim sequence number defect as fixed

* One framing rule and one response-command lookup, per the architecture review

* Apply the stability review's nits: honest sessionError docs and one link-survives assertion
This commit is contained in:
2026-09-05 21:00:52 +02:00
committed by GitHub
parent fec4fda082
commit ea42bc6d20
11 changed files with 511 additions and 141 deletions
+74 -3
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
import { PduRefusedError, isCommand, isResp, objToPdu, pduReturn, pduToObj, refusalAnswer } from '../src/pdu.ts';
function encode(...args: Parameters<typeof objToPdu>): Buffer {
const { buffer, err } = objToPdu(...args);
@@ -44,8 +44,11 @@ describe('header', () => {
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);
// SMPP 3.4 4.7.1 stops the range at 0x7FFFFFFF, but peers write the field as a plain uint32.
test('carries any 32-bit sequence number, and refuses one the field cannot hold', () => {
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0x80000001 })).seqNr, 0x80000001);
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error);
});
});
@@ -437,6 +440,74 @@ describe('malformed input', () => {
test('refuses an absurd command length instead of allocating for it', () => {
assert.ok(pduToObj(Buffer.from('ffffffff0000000400000000000000ff', 'hex')).err instanceof Error);
});
test('reads the header of a PDU it cannot parse, and names which part it choked on', () => {
const unknown = encode({ cmdName: 'enquire_link', seqNr: 9 });
unknown.writeUInt32BE(0x00010001, 4);
const refusedCommand = pduToObj(unknown).err;
assert.ok(refusedCommand instanceof PduRefusedError);
assert.equal(refusedCommand.reason, 'command');
assert.equal(refusedCommand.header.cmdName, undefined);
assert.equal(refusedCommand.header.cmdId, 0x00010001);
assert.equal(refusedCommand.header.seqNr, 9);
assert.deepEqual(refusalAnswer(refusedCommand), {
cmdName: 'generic_nack',
cmdStatus: 'ESME_RINVCMDID',
});
const whole = encode({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hello', source_addr: '46701113311' },
seqNr: 10,
});
// sm_length still declares five octets of short_message; three of them never arrived.
const short = whole.subarray(0, whole.length - 3);
short.writeUInt32BE(short.length, 0);
const refusedBody = pduToObj(short).err;
assert.ok(refusedBody instanceof PduRefusedError);
assert.equal(refusedBody.reason, 'body');
assert.equal(refusedBody.header.cmdName, 'deliver_sm');
assert.deepEqual(refusalAnswer(refusedBody), {
cmdName: 'deliver_sm_resp',
cmdStatus: 'ESME_RINVCMDLEN',
});
// message_state, declaring four octets of value with one of them on the wire.
const truncatedTlv = Buffer.concat([
encode({ cmdName: 'deliver_sm', params: { short_message: 'hello' }, seqNr: 11 }),
Buffer.from('0427000401', 'hex'),
]);
truncatedTlv.writeUInt32BE(truncatedTlv.length, 0);
const refusedTlvs = pduToObj(truncatedTlv).err;
assert.ok(refusedTlvs instanceof PduRefusedError);
assert.equal(refusedTlvs.reason, 'tlvs');
assert.deepEqual(refusalAnswer(refusedTlvs), {
cmdName: 'deliver_sm_resp',
cmdStatus: 'ESME_RINVTLVSTREAM',
});
});
test('answers a command with no response of its own with generic_nack', () => {
const outbind = encode({ cmdName: 'outbind', params: { system_id: 'smsc' }, seqNr: 12 });
// Both C-Octet Strings lose their terminator, so system_id runs off the end of the PDU.
const short = outbind.subarray(0, outbind.length - 2);
short.writeUInt32BE(short.length, 0);
const refused = pduToObj(short).err;
assert.ok(refused instanceof PduRefusedError);
assert.equal(refusalAnswer(refused).cmdName, 'generic_nack');
});
});
describe('isCommand()', () => {
+241 -54
View File
@@ -71,33 +71,66 @@ function raceWithin<T>(ms: number, promise: Promise<T>): Promise<T | false> {
return Promise.race([promise, delay(ms).then((): false => false)]);
}
type Peer = { close: () => Promise<void>; port: number };
/** A socket read as a queue of parsed PDUs; `handled` takes the ones the peer answers itself. */
function pduQueue(
sock: net.Socket,
handled: (pduObj: PduObject) => boolean = () => false,
): () => Promise<PduObject> {
const framer = new PduFramer();
const queue: PduObject[] = [];
const waiting: ((pduObj: PduObject) => void)[] = [];
/** Answers binds and nothing else, which is what a link the peer has stopped serving looks like. */
async function bindOnlyPeer(t: TestContext, options: { dropOn?: string } = {}): Promise<Peer> {
sock.on('data', chunk => {
framer.push(chunk);
for (const pdu of framer.next().pdus ?? []) {
const { pduObj } = pduToObj(pdu);
if (!pduObj || handled(pduObj)) continue;
const next = waiting.shift();
if (next) next(pduObj);
else queue.push(pduObj);
}
});
return () => {
const queued = queue.shift();
return queued ? Promise.resolve(queued) : once<PduObject>(resolve => waiting.push(resolve));
};
}
type Peer = {
close: () => Promise<void>;
/** The next PDU the ESME sent that the peer did not answer itself. */
next: () => Promise<PduObject>;
port: number;
/** Raw octets, so a test can say what objToPdu would not build. */
writeRaw: (bytes: Buffer) => void;
};
/** An SMSC answering binds and nothing else, which is what the tests write the other answers for. */
async function smscPeer(t: TestContext, options: { dropOn?: string } = {}): Promise<Peer> {
const sockets: net.Socket[] = [];
let queued: (() => Promise<PduObject>) | undefined;
const listener = net.createServer(sock => {
const framer = new PduFramer();
sockets.push(sock);
sock.on('data', chunk => {
framer.push(chunk);
queued = pduQueue(sock, pduObj => {
if (pduObj.cmdName === options.dropOn) {
sock.destroy();
for (const pdu of framer.next().pdus ?? []) {
const { pduObj } = pduToObj(pdu);
if (pduObj && pduObj.cmdName === options.dropOn) {
sock.destroy();
return;
}
if (!pduObj || !bindCommands.includes(pduObj.cmdName)) continue;
const { buffer } = pduReturn(pduObj, 'ESME_ROK', { system_id: 'silent' });
if (buffer) sock.write(buffer);
return true;
}
if (!bindCommands.includes(pduObj.cmdName)) return false;
const { buffer } = pduReturn(pduObj, 'ESME_ROK', { system_id: 'silent' });
if (buffer) sock.write(buffer);
return true;
});
});
@@ -112,7 +145,13 @@ async function bindOnlyPeer(t: TestContext, options: { dropOn?: string } = {}):
await new Promise<void>(resolve => { listener.close(() => { resolve(); }); });
},
next: () => {
assert.ok(queued, 'nothing has connected to the peer yet');
return queued();
},
port: typeof address === 'object' && address !== null ? address.port : 0,
writeRaw: (bytes: Buffer) => { sockets[sockets.length - 1]?.write(bytes); },
};
t.after(() => peer.close());
@@ -142,39 +181,14 @@ type RawPeer = {
/** A peer driven PDU by PDU, which is the only way to say things the client never says. */
function rawPeer(t: TestContext, port: number): RawPeer {
const framer = new PduFramer();
const queue: PduObject[] = [];
const waiting: ((pduObj: PduObject) => void)[] = [];
const sock = net.connect({ port });
sock.on('data', chunk => {
framer.push(chunk);
const { pdus } = framer.next();
for (const pdu of pdus ?? []) {
const { pduObj } = pduToObj(pdu);
if (!pduObj) continue;
const next = waiting.shift();
if (next) next(pduObj);
else queue.push(pduObj);
}
});
const next = pduQueue(sock);
t.after(() => { sock.destroy(); });
return {
close: () => { sock.destroy(); },
next: () => {
const queued = queue.shift();
return queued
? Promise.resolve(queued)
: once<PduObject>(resolve => waiting.push(resolve));
},
next,
write: input => {
const { buffer } = objToPdu(input);
@@ -218,7 +232,7 @@ describe('bind', () => {
// Plenty of SMSCs drop the connection on unbind instead of answering it.
test('takes a close that follows our unbind as a clean unbind', async t => {
const peer = await bindOnlyPeer(t, { dropOn: 'unbind' });
const peer = await smscPeer(t, { dropOn: 'unbind' });
const { session } = await client({ port: peer.port, responseTimeout: 2000 });
assert.ok(session);
@@ -227,7 +241,7 @@ describe('bind', () => {
});
test('still reports a close that lands on another in-flight request', async t => {
const peer = await bindOnlyPeer(t, { dropOn: 'enquire_link' });
const peer = await smscPeer(t, { dropOn: 'enquire_link' });
const { session } = await client({ port: peer.port, responseTimeout: 2000 });
assert.ok(session);
@@ -240,7 +254,7 @@ describe('bind', () => {
});
test('reports an unbind the peer left unanswered on a link that stays up', async t => {
const peer = await bindOnlyPeer(t);
const peer = await smscPeer(t);
const { session } = await client({ port: peer.port, responseTimeout: 150 });
assert.ok(session);
@@ -413,7 +427,7 @@ describe('bind', () => {
// The spec: an absent sc_interface_version means the SMSC supports no optional parameters.
test('takes an SMSC that declares no version as older than 3.4', async t => {
const peer = await bindOnlyPeer(t);
const peer = await smscPeer(t);
const { session } = await client({ port: peer.port });
assert.ok(session);
@@ -1189,6 +1203,179 @@ describe('robustness', () => {
});
});
describe('a PDU the codec cannot read', () => {
const receipt = {
destination_addr: '46709771337',
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: 'id:0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21 stat:DELIVRD err:000 text:',
source_addr: '46701113311',
};
/** The octets objToPdu built, wearing a sequence number it would refuse to write itself. */
function withSeqNr(input: PduObjectInput, seqNr: number): Buffer {
const { buffer } = objToPdu(input);
assert.ok(buffer);
buffer.writeUInt32BE(seqNr, 12);
return buffer;
}
/** command_length honoured, so the stream stays in sync, with the declared body cut short. */
function shortened(input: PduObjectInput, octets: number): Buffer {
const { buffer } = objToPdu(input);
assert.ok(buffer);
const cut = buffer.subarray(0, buffer.length - octets);
cut.writeUInt32BE(cut.length, 0);
return cut;
}
/** The same, with a message_state TLV declaring four octets of value and carrying one. */
function truncatedTlv(input: PduObjectInput): Buffer {
const { buffer } = objToPdu(input);
assert.ok(buffer);
const appended = Buffer.concat([buffer, Buffer.from('0427000401', 'hex')]);
appended.writeUInt32BE(appended.length, 0);
return appended;
}
/** The peer's next PDU within a budget: a dropped link must fail the test, not hang it. */
async function answerTo(peer: Peer): Promise<PduObject> {
const pduObj = await raceWithin(2000, peer.next());
assert.ok(pduObj, 'the peer was never answered');
return pduObj;
}
async function bound(t: TestContext, options: Parameters<typeof client>[0] = {}) {
const peer = await smscPeer(t);
const { session } = await client({ port: peer.port, ...options });
assert.ok(session);
closeAfter(t, session);
return { peer, session };
}
// ukarim/smscsim signs every deliver_sm it sends unprompted with a raw uint32, so about half
// land above SMPP 3.4 4.7.1's ceiling; refusing them cost the link (findings/01-smscsim.md).
test('answers a deliver_sm whose sequence number is above the spec range, and keeps the link', async t => {
const { peer, session } = await bound(t);
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
peer.writeRaw(withSeqNr({ cmdName: 'deliver_sm', params: receipt, seqNr: 1 }, 0x80000001));
const answered = await answerTo(peer);
assert.equal(answered.cmdName, 'deliver_sm_resp');
assert.equal(answered.cmdStatus, 'ESME_ROK');
assert.equal(answered.seqNr, 0x80000001);
const dlr = await raceWithin(2000, reported);
assert.ok(dlr, 'the receipt is a report, not a reason to drop the link');
assert.equal(dlr.statusMsg, 'DELIVERED');
peer.writeRaw(withSeqNr({ cmdName: 'enquire_link', seqNr: 1 }, 0xFFFFFFFF));
const pinged = await answerTo(peer);
assert.equal(pinged.cmdName, 'enquire_link_resp');
assert.equal(pinged.seqNr, 0xFFFFFFFF);
});
test('answers an unknown command id with generic_nack ESME_RINVCMDID', async t => {
const { peer, session } = await bound(t);
const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
const vendorSpecific = withSeqNr({ cmdName: 'enquire_link', seqNr: 1 }, 9);
vendorSpecific.writeUInt32BE(0x00010001, 4);
peer.writeRaw(vendorSpecific);
const answered = await answerTo(peer);
assert.equal(answered.cmdName, 'generic_nack');
assert.equal(answered.cmdStatus, 'ESME_RINVCMDID');
assert.equal(answered.seqNr, 9);
assert.ok((await raceWithin(2000, failed)) instanceof Error, 'one sessionError per refused PDU');
});
test('answers a deliver_sm with a truncated TLV stream with ESME_RINVTLVSTREAM', async t => {
const { peer, session } = await bound(t);
let reports = 0;
session.on('dlr', () => { reports++; });
peer.writeRaw(truncatedTlv({ cmdName: 'deliver_sm', params: receipt, seqNr: 5 }));
const answered = await answerTo(peer);
assert.equal(answered.cmdName, 'deliver_sm_resp');
assert.equal(answered.cmdStatus, 'ESME_RINVTLVSTREAM');
assert.equal(answered.seqNr, 5);
assert.equal(reports, 0, 'a refused PDU is not a report');
// The regression this fixes: the link, and the stream's sync, outlive the refused PDU.
peer.writeRaw(withSeqNr({ cmdName: 'enquire_link', seqNr: 1 }, 78));
assert.equal((await answerTo(peer)).cmdName, 'enquire_link_resp');
});
test('answers a deliver_sm whose body is shorter than it declares with ESME_RINVCMDLEN', async t => {
const { peer } = await bound(t);
peer.writeRaw(shortened({ cmdName: 'deliver_sm', params: receipt, seqNr: 6 }, 3));
const answered = await answerTo(peer);
assert.equal(answered.cmdName, 'deliver_sm_resp');
assert.equal(answered.cmdStatus, 'ESME_RINVCMDLEN');
assert.equal(answered.seqNr, 6);
});
test('settles the request a response it could not read was answering', async t => {
const { peer, session } = await bound(t, { responseTimeout: 60000 });
const sending = session.sendSms({ from: '46701113311', message: 'hi', to: '46709771337' });
const submitted = await answerTo(peer);
assert.equal(submitted.cmdName, 'submit_sm');
peer.writeRaw(truncatedTlv({
cmdName: 'submit_sm_resp',
params: { message_id: '0199e0ea-1c88-7a41-b6d2-4e7f0a9c3b15' },
seqNr: submitted.seqNr,
}));
const sent = await raceWithin(2000, sending);
assert.ok(sent, 'the request settles on the refusal rather than on the response timeout');
assert.ok(sent.err instanceof Error);
assert.equal(sent.unanswered, 1);
// Nothing goes back: a response carries a sequence number of ours, not one of the peer's.
peer.writeRaw(withSeqNr({ cmdName: 'enquire_link', seqNr: 1 }, 77));
assert.equal((await answerTo(peer)).cmdName, 'enquire_link_resp');
});
test('tears the link down when the stream itself cannot be framed', async t => {
const { peer, session } = await bound(t, { reconnect: false });
const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
// A command_length below the 16 octet header leaves nothing that can find the next PDU.
peer.writeRaw(Buffer.from('00000004000000150000000000000001', 'hex'));
assert.ok((await raceWithin(2000, failed)) instanceof Error);
assert.equal(await raceWithin(2000, closed), true);
});
});
describe('application hooks that throw or reject', () => {
test('turns a throwing authenticate into a session error', async t => {
const smpp = await startServer(t, {
@@ -1493,7 +1680,7 @@ describe('application hooks that throw or reject', () => {
describe('link timers', () => {
test('closes a client link the peer has stopped answering', async t => {
const peer = await bindOnlyPeer(t);
const peer = await smscPeer(t);
const { err, session } = await client({
enquireLinkInterval: 50,
port: peer.port,
@@ -1513,7 +1700,7 @@ describe('link timers', () => {
});
test('reconnects a link that timed out', async t => {
const peer = await bindOnlyPeer(t);
const peer = await smscPeer(t);
const { session } = await client({
enquireLinkInterval: 40,
port: peer.port,