Read a delivery receipt's body as the octets that arrived (#81)

* Regression tests for a receipt body read under a data_coding it is not written in

* Read a delivery receipt's body as the octets that arrived, not by its data_coding

* Assert SMPPSim's UCS2 receipt body parses like every other segment's

* Record the receipt-body defect as fixed

* Retain a multipart segment's octets once, not twice

* Sort the new type import into its file's order
This commit is contained in:
2026-09-05 22:37:05 +02:00
committed by GitHub
parent ea42bc6d20
commit 45d2f5548e
19 changed files with 300 additions and 162 deletions
+15 -1
View File
@@ -87,6 +87,7 @@ src/
outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
pdu-refusal.ts A PDU the codec would not read, and the answer SMPP names for it
pdu-transport.ts PduTransport: the socket a session reads complete PDUs off pdu-transport.ts PduTransport: the socket a session reads complete PDUs off
pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort
reassembly.ts Reassembler: capped, expiring multipart groups reassembly.ts Reassembler: capped, expiring multipart groups
@@ -96,7 +97,7 @@ src/
send-window.ts SendWindow: the maxOutstanding semaphore send-window.ts SendWindow: the maxOutstanding semaphore
session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
sms-id.ts The notation a peer writes message ids in, normalised for comparison sms-id.ts The notation a peer writes message ids in, normalised for comparison
udh.ts User data header: the concatenation fields of a long SMS, and their reference udh.ts User data header: its length, the concatenation fields of a long SMS and their reference
unanswered-error.ts UnansweredError: it went out and no answer came back unanswered-error.ts UnansweredError: it went out and no answer came back
uuid.ts uuidv7() — the ids the library generates for messages uuid.ts uuidv7() — the ids the library generates for messages
defs/ defs/
@@ -289,6 +290,19 @@ Grouped by what each one constrains.
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body. the body.
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss
against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads
`PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a
character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings
because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding`
where the octets yield no field, which reads one body two ways for the sake of a peer writing a
UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here,
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
but `data_coding` can say how a message was written.
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and - **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final, `SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken `UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
+6 -2
View File
@@ -137,8 +137,9 @@ session.on('sms', async sms => {
Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has
to tell the two apart. `esm_class` is what tells them apart; where it names no message type a to tell the two apart. `esm_class` is what tells them apart; where it names no message type a
`receipted_message_id` TLV does, and failing both the message body is read for the standard `receipted_message_id` TLV does, and failing both the message body is read for the standard
`id:` and `stat:` receipt fields. An intermediate delivery notification is the SMSC reporting as `id:` and `stat:` receipt fields. That body is read as text whatever `data_coding` the receipt
well, not an inbound message. declares, since SMSCs commonly copy the reported message's onto it. An intermediate delivery
notification is the SMSC reporting as well, not an inbound message.
Matching a receipt to a send means comparing `dlr.smsId` against the `smsIds` that `sendSms()` Matching a receipt to a send means comparing `dlr.smsId` against the `smsIds` that `sendSms()`
returned. Some SMSCs write the two in different notations — a hex `message_id` on the returned. Some SMSCs write the two in different notations — a hex `message_id` on the
@@ -383,6 +384,9 @@ if (isCommand(pduObj, 'submit_sm')) {
} }
``` ```
`params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that
same field exactly as it arrived.
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
`types`, and the matching `*ById` maps) and grouped as `defs`. `types`, and the matching `*ById` maps) and grouped as `defs`.
+3 -1
View File
@@ -52,7 +52,7 @@ tests passing, `malformed: 0`, `expert errors: 0` in both.
| C5 (single-state variants) | pass | `smppsim single-state variants - C5 …`; UNDELIVERABLE/REJECTED/ACCEPTED each map correctly; 2-segment message's shared status confirmed, `messageDlr` confirmed absent (see Open questions) | | C5 (single-state variants) | pass | `smppsim single-state variants - C5 …`; UNDELIVERABLE/REJECTED/ACCEPTED each map correctly; 2-segment message's shared status confirmed, `messageDlr` confirmed absent (see Open questions) |
| C6 (`smppsim-delayed`) | pass | `smppsim-delayed - C6 …`; disconnect+reconnect observed, delayed receipt still reaches `dlr` on the new link | | C6 (`smppsim-delayed`) | pass | `smppsim-delayed - C6 …`; disconnect+reconnect observed, delayed receipt still reaches `dlr` on the new link |
| C7 (loopback, GSM 1/2/3/10-segment) | pass | same `C3+C7` suite; one id per segment, receipt per id, loopback reassembles the exact text including € and \[ \] | | C7 (loopback, GSM 1/2/3/10-segment) | pass | same `C3+C7` suite; one id per segment, receipt per id, loopback reassembles the exact text including € and \[ \] |
| C7 (loopback, UCS2 2-segment) | pass with a documented defect | `… 2-segment UCS2 …: TLVs stay right, the receipt body is corrupted`; TLVs and loopback reassembly both correct, receipt body unreadable - `@larvit/smpp` defect below | | C7 (loopback, UCS2 2-segment) | pass with a defect, since fixed | `… 2-segment UCS2 …`; TLVs and loopback reassembly both correct, receipt body unreadable at this commit - `@larvit/smpp` defect below |
| C11 (bind refusal + backoff) | pass | `smppsim - C11 …`, three sub-tests, see below for what each shows | | C11 (bind refusal + backoff) | pass | `smppsim - C11 …`, three sub-tests, see below for what each shows |
| C12 (`smppsim-queuefull`) | pass | `smppsim-queuefull - C12 …`; `ESME_RMSGQFUL` returned, session stays bound, later send succeeds once the one-slot queue drains | | C12 (`smppsim-queuefull`) | pass | `smppsim-queuefull - C12 …`; `ESME_RMSGQFUL` returned, session stays bound, later send succeeds once the one-slot queue drains |
| C13 (`maxOutstanding: 1`, 10 parallel) | pass | `smppsim - C13 …`; 10 distinct, strictly-increasing ids, none lost | | C13 (`maxOutstanding: 1`, 10 parallel) | pass | `smppsim - C13 …`; 10 distinct, strictly-increasing ids, none lost |
@@ -96,6 +96,8 @@ left to fall back on. Not reproduced against `smppsim-textdlr` here (that varian
only sends ASCII), so this is inferred from the mechanism, not independently confirmed there - see only sends ASCII), so this is inferred from the mechanism, not independently confirmed there - see
Open questions. Open questions.
**Fixed** in PR #81: a receipt body is read as the octets that arrived, never by its `data_coding`.
### `reconnect` never retries the very first connect or bind attempt ### `reconnect` never retries the very first connect or bind attempt
**What happened.** `client()` performs the socket connect and the initial bind directly, not **What happened.** `client()` performs the socket connect and the initial bind directly, not
+21 -67
View File
@@ -4,7 +4,6 @@ import type { Dlr } from '../src/dlr.ts';
import type { EncodingName } from '../src/defs/encodings.ts'; import type { EncodingName } from '../src/defs/encodings.ts';
import type { MessageDlr } from '../src/dlr-merger.ts'; import type { MessageDlr } from '../src/dlr-merger.ts';
import type { PduObject } from '../src/pdu.ts'; import type { PduObject } from '../src/pdu.ts';
import type { SendSmsResult } from '../src/send-sms.ts';
import type { Session } from '../src/session.ts'; import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts'; import type { Sms } from '../src/sms.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
@@ -164,11 +163,20 @@ async function sendUntilComplete(
} }
describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
const cases: { expectedSegments: number; label: string; message: string }[] = [ const cases: { encoding?: EncodingName; expectedSegments: number; label: string; message: string }[] = [
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: gsmFiller(100) }, { expectedSegments: 1, label: 'single-segment GSM with extension chars', message: gsmFiller(100) },
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: gsmFiller(200) }, { expectedSegments: 2, label: '2-segment GSM with extension chars', message: gsmFiller(200) },
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: gsmFiller(400) }, { expectedSegments: 3, label: '3-segment GSM with extension chars', message: gsmFiller(400) },
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: gsmFiller(1450) }, { expectedSegments: 10, label: '10-segment GSM with extension chars', message: gsmFiller(1450) },
// SMPPSim writes this one's receipt body as text under the data_coding of the submit it
// reports on (8, UCS2), so it holds to the same bar as the GSM cases only if the body is
// read as the octets that arrived.
{
encoding: 'UCS2',
expectedSegments: 2,
label: '2-segment UCS2 with 一 and an emoji',
message: `一😀${'x'.repeat(70)}`,
},
]; ];
for (const testCase of cases) { for (const testCase of cases) {
@@ -182,7 +190,13 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
const dlrs = collectDlrs(session); const dlrs = collectDlrs(session);
const sms = collectSms(session); const sms = collectSms(session);
const { reassembled, smsIds } = await sendUntilComplete(session, dlrs, sms, testCase.message); const { reassembled, smsIds } = await sendUntilComplete(
session,
dlrs,
sms,
testCase.message,
testCase.encoding,
);
assert.equal(smsIds.length, testCase.expectedSegments); assert.equal(smsIds.length, testCase.expectedSegments);
@@ -195,75 +209,15 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
// match, which is what a well-behaved SMSC gives you (C3). // match, which is what a well-behaved SMSC gives you (C3).
assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id); assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id);
assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED); assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED);
assert.equal(received.dlr.receipt?.stat, 'DELIVRD'); assert.ok(received.dlr.receipt);
assert.equal(received.dlr.receipt.stat, 'DELIVRD');
assert.equal(received.dlr.receipt.id, id);
assert.equal(received.dlr.receipt.err, '000');
} }
assert.equal((await reassembled.sendResp()).err, undefined); assert.equal((await reassembled.sendResp()).err, undefined);
}); });
} }
test('2-segment UCS2 with 一 and an emoji: TLVs stay right, the receipt body is corrupted', async t => {
const { err, session } = await bind(PEER_HOST);
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const dlrs = collectDlrs(session);
const sms = collectSms(session);
const message = `一😀${'x'.repeat(70)}`;
let smsIds: string[] | undefined;
let reassembled: Sms | undefined;
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS && !reassembled; attempt++) {
const sent: SendSmsResult = await session.sendSms(
{ dlr: true, encoding: 'UCS2', from: FROM, message, to: TO },
);
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, 2);
const complete = await waitFor(() => {
const tlvsIntact = sent.smsIds.every(id => {
const received = dlrs.find(r => r.dlr.smsId === id);
return received?.pduObj.tlvs.receipted_message_id?.tagValue !== undefined
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
});
const found = sms.find(s => s.message === message);
return tlvsIntact && found ? { found } : undefined;
}, DLR_RETRY_BUDGET_MS);
if (complete) {
smsIds = sent.smsIds;
reassembled = complete.found;
}
}
assert.ok(smsIds);
assert.ok(reassembled, 'expected the loopback deliver_sm(s), unaffected by the defect, to reassemble');
for (const id of smsIds) {
const received = dlrs.find(r => r.dlr.smsId === id);
assert.ok(received);
// The TLVs are typed fields, unaffected by the defect below.
assert.equal(received.dlr.statusMsg, 'DELIVERED');
assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id);
assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED);
// Defect (see findings/02-smppsim.md): SMPPSim's delivery receipt echoes the
// original submit_sm's data_coding (8, UCS2) but writes its short_message as plain
// ASCII text. pdu.ts decodes short_message for any non-UDH PDU using that same
// data_coding at parse time, so the ASCII receipt bytes are read back as UCS2 -
// every "stat:"/"id:" field becomes unrecoverable CJK-range garbage.
assert.equal(received.dlr.receipt?.stat, undefined);
}
assert.equal((await reassembled.sendResp()).err, undefined);
});
}); });
describe('smppsim-textdlr - C2 text-only receipts', () => { describe('smppsim-textdlr - C2 text-only receipts', () => {
+9
View File
@@ -265,3 +265,12 @@ export function commandNameById(id: number): CommandName | undefined {
return isCommandName(command) ? command : undefined; return isCommandName(command) ? command : undefined;
} }
/** The response SMPP pairs with a request command, where it has one. */
export function respNameFor(cmdName: CommandName | undefined): CommandName | undefined {
if (cmdName === undefined) return undefined;
const respName = `${cmdName}_resp`;
return isCommandName(respName) ? respName : undefined;
}
+11 -10
View File
@@ -2,10 +2,11 @@ import type { MessageState } from './defs/constants.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts'; import type { SmsIdFormat } from './sms-id.ts';
import { consts, constsById, messageTypeOf } from './defs/constants.ts'; import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
import { decodeMessage } from './message.ts'; import { encodings } from './defs/encodings.ts';
import { normaliseSmsId } from './sms-id.ts'; import { normaliseSmsId } from './sms-id.ts';
import { paramNumber, paramText } from './defs/types.ts'; import { paramNumber, paramText } from './defs/types.ts';
import { udhLength } from './udh.ts';
/** /**
* The seven-character status codes carried in a receipt's `stat:` field, mapped to the * The seven-character status codes carried in a receipt's `stat:` field, mapped to the
@@ -154,17 +155,17 @@ function messageType(pduObj: PduObject): MessageType {
return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt'; return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt';
} }
/** A UDH-carrying short_message reaches here as a buffer, header and all. */ /** SMPP 3.4 Appendix B makes a receipt fixed text, so its octets are read as octets, not decoded. */
function receiptBody(pduObj: PduObject): string { function receiptBody(pduObj: PduObject): string {
const message = pduObj.params.short_message; const octets = pduObj.shortMessageOctets;
if (!Buffer.isBuffer(message)) return paramText(message); if (octets === undefined) return paramText(pduObj.params.short_message);
return decodeMessage( const body = hasUdh(paramNumber(pduObj.params.esm_class, 0))
message, ? octets.subarray(udhLength(octets))
paramNumber(pduObj.params.data_coding, 0), : octets;
paramNumber(pduObj.params.esm_class, 0),
).message; return encodings.LATIN1.decode(body);
} }
function receiptId( function receiptId(
+2 -1
View File
@@ -12,13 +12,14 @@ export { types } from './defs/types.ts';
export { export {
isCommand, isCommand,
isResp, isResp,
maxPduLength,
maxSeqNr, maxSeqNr,
objToPdu, objToPdu,
pduReturn, pduReturn,
pduToObj, pduToObj,
} from './pdu.ts'; } from './pdu.ts';
export { maxPduLength } from './pdu-refusal.ts';
export { export {
bitCount, bitCount,
decodeMessage, decodeMessage,
+5 -4
View File
@@ -1,7 +1,8 @@
import type { Result } from './result.ts'; import type { Result } from './result.ts';
import type { EncodingName } from './defs/encodings.ts'; import type { EncodingName } from './defs/encodings.ts';
import { hasUdh } from './defs/constants.ts';
import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts'; import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
import { hasUdh } from './defs/constants.ts';
import { udhLength } from './udh.ts';
/** A single SMS carries 1120 bits, whatever the alphabet. */ /** A single SMS carries 1120 bits, whatever the alphabet. */
const singleMessageBits = 1120; const singleMessageBits = 1120;
@@ -37,11 +38,11 @@ export function decodeMessage(
return { message: encodings[encoding].decode(buffer), udh: undefined }; return { message: encodings[encoding].decode(buffer), udh: undefined };
} }
const udhLength = (buffer[0] ?? 0) + 1; const headerLength = udhLength(buffer);
return { return {
message: encodings[encoding].decode(buffer.subarray(udhLength)), message: encodings[encoding].decode(buffer.subarray(headerLength)),
udh: buffer.subarray(0, udhLength), udh: buffer.subarray(0, headerLength),
}; };
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Result } from './result.ts'; import type { Result } from './result.ts';
import { framingRefusal } from './pdu.ts'; import { framingRefusal } from './pdu-refusal.ts';
/** /**
* Cuts a byte stream into whole PDUs. * Cuts a byte stream into whole PDUs.
+57
View File
@@ -0,0 +1,57 @@
import type { CommandName } from './defs/commands.ts';
import type { ErrorName } from './defs/errors.ts';
import { respNameFor } from './defs/commands.ts';
/** A hostile peer must not be able to make us allocate arbitrarily. */
export const maxPduLength = 1024 * 1024;
/** The 16 octets a framed PDU always has, whatever its body turns out to be. */
export type PduHeader = {
cmdId: number;
cmdLength: number;
cmdName: CommandName | undefined;
cmdStatusId: number;
seqNr: number;
};
/** Which part of a PDU the codec could not read. */
export type PduRefusalReason = 'body' | 'command' | 'tlvs';
/** A PDU refused with the stream still in sync, so only this one PDU is lost. */
export class PduRefusedError extends Error {
readonly header: PduHeader;
readonly reason: PduRefusalReason;
constructor(header: PduHeader, reason: PduRefusalReason, cause: Error) {
const named = header.cmdName ?? `command id ${String(header.cmdId)}`;
super(`Refused ${named} with seqNr ${String(header.seqNr)}: ${cause.message}`, { cause });
this.header = header;
this.name = 'PduRefusedError';
this.reason = reason;
}
}
// ESME_RINVTLVSTREAM is SMPP 5.0's name for 0xC0, which SMPP 3.4 spells ESME_RINVOPTPARSTREAM.
const refusalStatus = {
body: 'ESME_RINVCMDLEN',
command: 'ESME_RINVCMDID',
tlvs: 'ESME_RINVTLVSTREAM',
} as const satisfies Record<PduRefusalReason, ErrorName>;
/** Why a command_length cannot frame a stream: past it nothing can say where the next PDU starts. */
export function framingRefusal(cmdLength: number): Error | undefined {
if (cmdLength < 16 || cmdLength > maxPduLength) {
return new Error(`Refusing a cmd_length of ${String(cmdLength)}`);
}
return undefined;
}
/** SMPP 3.4 4.3: a PDU whose command has no response of its own is refused with generic_nack. */
export function refusalAnswer(refused: PduRefusedError): { cmdName: CommandName; cmdStatus: ErrorName } {
return {
cmdName: respNameFor(refused.header.cmdName) ?? 'generic_nack',
cmdStatus: refusalStatus[refused.reason],
};
}
+2 -1
View File
@@ -3,7 +3,8 @@ import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import type { VoidResult } from './result.ts'; import type { VoidResult } from './result.ts';
import { PduFramer } from './pdu-framer.ts'; import { PduFramer } from './pdu-framer.ts';
import { PduRefusedError, pduToObj } from './pdu.ts'; import { PduRefusedError } from './pdu-refusal.ts';
import { pduToObj } from './pdu.ts';
export type PduTransportOptions = { export type PduTransportOptions = {
log: SmppLog; log: SmppLog;
+9 -67
View File
@@ -1,9 +1,11 @@
import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduHeader } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Tlv } from './defs/tlvs.ts'; import type { Tlv } from './defs/tlvs.ts';
import { cmds, commandNameById, isCommandName } from './defs/commands.ts'; import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { consts, hasUdh } from './defs/constants.ts'; import { consts, hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeMessage } from './message.ts'; import { decodeMessage, encodeMessage } from './message.ts';
import { detect, encodingByDataCoding } from './defs/encodings.ts'; import { detect, encodingByDataCoding } from './defs/encodings.ts';
@@ -17,18 +19,6 @@ export const maxSeqNr = 2147483646;
/** What the field holds. Read and echoed in full, because peers do write above the spec's range. */ /** What the field holds. Read and echoed in full, because peers do write above the spec's range. */
const maxWireSeqNr = 0xFFFFFFFF; const maxWireSeqNr = 0xFFFFFFFF;
/** A hostile peer must not be able to make us allocate arbitrarily. */
export const maxPduLength = 1024 * 1024;
/** Why a command_length cannot frame a stream: past it nothing can say where the next PDU starts. */
export function framingRefusal(cmdLength: number): Error | undefined {
if (cmdLength < 16 || cmdLength > maxPduLength) {
return new Error(`Refusing a cmd_length of ${String(cmdLength)}`);
}
return undefined;
}
export type TlvInput = { export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */ /** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined; tagId?: number | undefined;
@@ -55,60 +45,11 @@ export type PduObject = {
cmdStatusId: number; cmdStatusId: number;
params: Record<string, ParamValue>; params: Record<string, ParamValue>;
seqNr: number; seqNr: number;
/** short_message as it arrived, whatever data_coding turned `params.short_message` into. */
shortMessageOctets: Buffer | undefined;
tlvs: Record<string, Tlv>; tlvs: Record<string, Tlv>;
}; };
/** The 16 octets a framed PDU always has, whatever its body turns out to be. */
export type PduHeader = {
cmdId: number;
cmdLength: number;
cmdName: CommandName | undefined;
cmdStatusId: number;
seqNr: number;
};
/** Which part of a PDU the codec could not read. */
export type PduRefusalReason = 'body' | 'command' | 'tlvs';
/** A PDU refused with the stream still in sync, so only this one PDU is lost. */
export class PduRefusedError extends Error {
readonly header: PduHeader;
readonly reason: PduRefusalReason;
constructor(header: PduHeader, reason: PduRefusalReason, cause: Error) {
const named = header.cmdName ?? `command id ${String(header.cmdId)}`;
super(`Refused ${named} with seqNr ${String(header.seqNr)}: ${cause.message}`, { cause });
this.header = header;
this.name = 'PduRefusedError';
this.reason = reason;
}
}
// ESME_RINVTLVSTREAM is SMPP 5.0's name for 0xC0, which SMPP 3.4 spells ESME_RINVOPTPARSTREAM.
const refusalStatus = {
body: 'ESME_RINVCMDLEN',
command: 'ESME_RINVCMDID',
tlvs: 'ESME_RINVTLVSTREAM',
} as const satisfies Record<PduRefusalReason, ErrorName>;
/** The response SMPP pairs with a request command, where it has one. */
function respNameFor(cmdName: CommandName | undefined): CommandName | undefined {
if (cmdName === undefined) return undefined;
const respName = `${cmdName}_resp`;
return isCommandName(respName) ? respName : undefined;
}
/** SMPP 3.4 4.3: a PDU whose command has no response of its own is refused with generic_nack. */
export function refusalAnswer(refused: PduRefusedError): { cmdName: CommandName; cmdStatus: ErrorName } {
return {
cmdName: respNameFor(refused.header.cmdName) ?? 'generic_nack',
cmdStatus: refusalStatus[refused.reason],
};
}
const respBit = 0x80000000; const respBit = 0x80000000;
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean { export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
@@ -391,11 +332,11 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
const params = read.params; const params = read.params;
const message = params.short_message; const message = params.short_message;
const esmClass = paramNumber(params.esm_class, 0); const octets = Buffer.isBuffer(message) ? message : undefined;
// A message carrying a UDH stays a buffer; the session needs the header intact to reassemble. // A message carrying a UDH stays a buffer; the session needs the header intact to reassemble.
if (Buffer.isBuffer(message) && !hasUdh(esmClass)) { if (octets && !hasUdh(paramNumber(params.esm_class, 0))) {
params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message; params.short_message = decodeMessage(octets, paramNumber(params.data_coding, 0)).message;
} }
return { return {
@@ -408,6 +349,7 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
cmdStatusId, cmdStatusId,
params, params,
seqNr, seqNr,
shortMessageOctets: octets,
tlvs: parsed.tlvs, tlvs: parsed.tlvs,
}, },
}; };
+6 -1
View File
@@ -39,7 +39,12 @@ function detach(pduObj: PduObject): PduObject {
: tlv; : tlv;
} }
return { ...pduObj, params, tlvs }; // short_message holds the same octets wherever it was not decoded, so one copy covers both.
const octets = Buffer.isBuffer(params.short_message)
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
} }
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU. // A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
+4 -2
View File
@@ -1,7 +1,8 @@
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { MessageDlr } from './dlr-merger.ts'; import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, PduRefusedError, TlvInput } from './pdu.ts'; import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; import type { BindType, CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
@@ -18,7 +19,8 @@ import { leftOf } from './idle-waiters.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { optionalParamsMinVersion } from './defs/constants.ts'; import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts'; import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { isResp, objToPdu, pduReturn, refusalAnswer } from './pdu.ts'; import { isResp, objToPdu, pduReturn } from './pdu.ts';
import { refusalAnswer } from './pdu-refusal.ts';
import { guardedLog } from './log.ts'; import { guardedLog } from './log.ts';
import { submitSms, unsent } from './send-sms.ts'; import { submitSms, unsent } from './send-sms.ts';
import { ConcatReference } from './udh.ts'; import { ConcatReference } from './udh.ts';
+5
View File
@@ -9,6 +9,11 @@ export class ConcatReference {
} }
} }
/** A user data header is as long as its first octet says, that octet included. */
export function udhLength(message: Buffer): number {
return (message[0] ?? 0) + 1;
}
export type ConcatInfo = { export type ConcatInfo = {
part: number; part: number;
reference: number; reference: number;
+72 -2
View File
@@ -2,19 +2,26 @@ import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import { consts } from '../src/defs/constants.ts'; import { consts } from '../src/defs/constants.ts';
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts'; import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
import { encodeMessage } from '../src/message.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts'; import { objToPdu, pduToObj } from '../src/pdu.ts';
import type { PduObject, TlvInput } from '../src/pdu.ts'; import type { PduObject, TlvInput } from '../src/pdu.ts';
const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello there'; const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello there';
/** SMPPSim writes this body as plain text under the data_coding of the message it reports on. */
const textReceiptId = '01a072f9-30f0-7807-945f-3412d4d5b8c3';
const textReceipt = `id:${textReceiptId} sub:001 dlvrd:001 submit date:2509051430 done date:2509051431 stat:DELIVRD err:000 Text:hello there`;
function deliverSm( function deliverSm(
message: Buffer | string, message: Buffer | string,
tlvs?: Record<string, TlvInput>, tlvs?: Record<string, TlvInput>,
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT, esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
dataCoding = 0,
): PduObject { ): PduObject {
const { buffer } = objToPdu({ const { buffer } = objToPdu({
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
params: { params: {
data_coding: dataCoding,
destination_addr: '46701113311', destination_addr: '46701113311',
esm_class: esmClass, esm_class: esmClass,
short_message: message, short_message: message,
@@ -149,8 +156,8 @@ describe('dlrFromPdu()', () => {
assert.equal(dlr.intermediate, false); assert.equal(dlr.intermediate, false);
}); });
// pduToObj leaves a UDH-carrying short_message a buffer, so the body needs decoding before it // The message type sits under the UDH indicator in the same octet, and the header has to come
// can be read at all — and the message type sits under the UDH indicator in the same octet. // off the body before any of it can be read.
test('reads the body of a receipt that carries a UDH', () => { test('reads the body of a receipt that carries a UDH', () => {
const udh = Buffer.from([0x05, 0x00, 0x03, 0x2a, 0x01, 0x01]); const udh = Buffer.from([0x05, 0x00, 0x03, 0x2a, 0x01, 0x01]);
const body = Buffer.concat([udh, Buffer.from(receiptText, 'ascii')]); const body = Buffer.concat([udh, Buffer.from(receiptText, 'ascii')]);
@@ -158,6 +165,7 @@ describe('dlrFromPdu()', () => {
body, body,
undefined, undefined,
consts.ESM_CLASS.MC_DELIVERY_RECEIPT | consts.ESM_CLASS.UDH_INDICATOR, consts.ESM_CLASS.MC_DELIVERY_RECEIPT | consts.ESM_CLASS.UDH_INDICATOR,
consts.ENCODING.UCS2,
)); ));
assert.ok(dlr); assert.ok(dlr);
@@ -165,6 +173,68 @@ describe('dlrFromPdu()', () => {
assert.equal(dlr.statusMsg, 'DELIVERED'); assert.equal(dlr.statusMsg, 'DELIVERED');
}); });
test('reads a text receipt out of a PDU whose data_coding declares UCS2', () => {
const dlr = dlrFromPdu(deliverSm(
Buffer.from(textReceipt, 'ascii'),
{
message_state: { tagValue: consts.MESSAGE_STATE.DELIVERED },
receipted_message_id: { tagValue: textReceiptId },
},
consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
consts.ENCODING.UCS2,
));
assert.ok(dlr?.receipt);
assert.equal(dlr.receipt.id, textReceiptId);
assert.equal(dlr.receipt.sub, 1);
assert.equal(dlr.receipt.dlvrd, 1);
assert.equal(dlr.receipt.submitDate, '2509051430');
assert.equal(dlr.receipt.doneDate, '2509051431');
assert.equal(dlr.receipt.stat, 'DELIVRD');
assert.equal(dlr.receipt.err, '000');
assert.equal(dlr.receipt.text, 'hello there');
assert.equal(dlr.smsId, textReceiptId);
assert.equal(dlr.statusMsg, 'DELIVERED');
});
test('reads that same receipt with no TLVs to fall back on', () => {
const dlr = dlrFromPdu(deliverSm(
Buffer.from(textReceipt, 'ascii'),
undefined,
consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
consts.ENCODING.UCS2,
));
assert.ok(dlr);
assert.equal(dlr.smsId, textReceiptId);
assert.equal(dlr.statusMsg, 'DELIVERED');
assert.equal(dlr.statusId, consts.MESSAGE_STATE.DELIVERED);
});
// A receipt a peer really did write in UCS2 costs exactly this: undetermined, never guessed at.
test('reports a receipt body it cannot read either way as undetermined', () => {
const dlr = dlrFromPdu(deliverSm(
encodeMessage(`id:${textReceiptId} stat:DELIVRD err:000`, 'UCS2').buffer,
undefined,
consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
consts.ENCODING.UCS2,
));
assert.ok(dlr);
assert.equal(dlr.receipt?.id, undefined);
assert.equal(dlr.receipt?.stat, undefined);
assert.equal(dlr.smsId, undefined);
assert.equal(dlr.statusMsg, 'UNKNOWN');
assert.equal(dlr.statusId, consts.MESSAGE_STATE.UNKNOWN);
});
test('leaves a UCS2 message to arrive as an SMS, decoded by its data_coding', () => {
const pduObj = deliverSm(encodeMessage('hej 一', 'UCS2').buffer, undefined, 0, consts.ENCODING.UCS2);
assert.equal(dlrFromPdu(pduObj), undefined);
assert.equal(pduObj.params.short_message, 'hej 一');
});
// message_state 0x80-0xFF is reserved for MC-vendor-specific values, which we cannot name. // message_state 0x80-0xFF is reserved for MC-vendor-specific values, which we cannot name.
test('falls back to the body when the state TLV carries a value it cannot name', () => { test('falls back to the body when the state TLV carries a value it cannot name', () => {
const dlr = dlrFromPdu(deliverSm(receiptText, { message_state: { tagValue: 0x84 } })); const dlr = dlrFromPdu(deliverSm(receiptText, { message_state: { tagValue: 0x84 } }));
+37 -1
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import { PduRefusedError, isCommand, isResp, objToPdu, pduReturn, pduToObj, refusalAnswer } from '../src/pdu.ts'; import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts';
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts';
function encode(...args: Parameters<typeof objToPdu>): Buffer { function encode(...args: Parameters<typeof objToPdu>): Buffer {
const { buffer, err } = objToPdu(...args); const { buffer, err } = objToPdu(...args);
@@ -206,6 +208,40 @@ describe('encoding submit_sm', () => {
}); });
}); });
describe('decoding short_message', () => {
function deliverSm(dataCoding: number, message: Buffer | string) {
return decode(encode({
cmdName: 'deliver_sm',
params: {
data_coding: dataCoding,
destination_addr: '46709771337',
short_message: message,
source_addr: '46701113311',
},
seqNr: 3,
}));
}
test('reads an inbound message with the data_coding the PDU declares', () => {
const binary = Buffer.from([0x00, 0x1B, 0x60, 0x80, 0xFF]);
assert.equal(deliverSm(0x08, 'hej 一').params.short_message, 'hej 一');
assert.equal(deliverSm(0x03, Buffer.from([0xE1, 0xE7, 0xDA])).params.short_message, 'áçÚ');
assert.deepEqual(
Buffer.from(paramText(deliverSm(0x04, binary).params.short_message), 'latin1'),
binary,
);
});
// A delivery receipt is read from these rather than from the text above, since its body is
// Appendix B's fixed format whatever the data_coding the peer inherited onto it says.
test('keeps the octets that arrived alongside the text they decoded to', () => {
const pduObj = deliverSm(0x08, 'hej 一');
assert.deepEqual(pduObj.shortMessageOctets, Buffer.from('hej 一', 'utf16le').swap16());
});
});
describe('encoding submit_multi', () => { describe('encoding submit_multi', () => {
const dest = { dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46709771337' }; const dest = { dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46709771337' };
+5 -1
View File
@@ -83,6 +83,7 @@ function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject
cmdStatusId: 0, cmdStatusId: 0,
params: { destination_addr: '46709771337', short_message: 'held', source_addr: '46701113311' }, params: { destination_addr: '46709771337', short_message: 'held', source_addr: '46701113311' },
seqNr, seqNr,
shortMessageOctets: undefined,
tlvs: {}, tlvs: {},
}; };
} }
@@ -313,6 +314,7 @@ describe('sendSms()', () => {
cmdStatusId: errors[status], cmdStatusId: errors[status],
params: { message_id: messageId }, params: { message_id: messageId },
seqNr, seqNr,
shortMessageOctets: undefined,
tlvs: {}, tlvs: {},
}; };
} }
@@ -1153,6 +1155,7 @@ describe('sendDlr()', () => {
describe('reassembly bounds', () => { describe('reassembly bounds', () => {
function segment(reference: number, part: number, total: number): PduObject { function segment(reference: number, part: number, total: number): PduObject {
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]); const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
const body = Buffer.concat([udh, Buffer.from('fragment')]);
return { return {
cmdId: 0x00000004, cmdId: 0x00000004,
@@ -1164,10 +1167,11 @@ describe('reassembly bounds', () => {
data_coding: 0, data_coding: 0,
destination_addr: '46709771337', destination_addr: '46709771337',
esm_class: 0x40, esm_class: 0x40,
short_message: Buffer.concat([udh, Buffer.from('fragment')]), short_message: body,
source_addr: '46701113311', source_addr: '46701113311',
}, },
seqNr: part, seqNr: part,
shortMessageOctets: body,
tlvs: {}, tlvs: {},
}; };
} }
+30
View File
@@ -168,6 +168,7 @@ function enquireLink(seqNr: number): PduObject {
cmdStatusId: 0, cmdStatusId: 0,
params: {}, params: {},
seqNr, seqNr,
shortMessageOctets: undefined,
tlvs: {}, tlvs: {},
}; };
} }
@@ -749,6 +750,35 @@ describe('receiving', () => {
assert.equal(answeredNotification.pduObj.cmdName, 'deliver_sm_resp'); assert.equal(answeredNotification.pduObj.cmdName, 'deliver_sm_resp');
}); });
// SMPPSim's receipt for a UCS2 message inherits its data_coding and writes the body as text.
test('parses a receipt written as text under a data_coding that says UCS2', async t => {
const { peer, session } = await inbound(t);
const reported = once<{ dlr: Dlr; pduObj: PduObject }>(resolve => {
session.on('dlr', (dlr, pduObj) => { resolve({ dlr, pduObj }); });
});
const smsId = '01a072f9-30f2-71b0-87cd-f5032df3a8e0';
const body = `id:${smsId} sub:001 dlvrd:001 submit date:2509051430 done date:2509051431 stat:DELIVRD err:000 text:`;
const delivered = peer.send({
cmdName: 'deliver_sm',
params: {
data_coding: consts.ENCODING.UCS2,
destination_addr: '46709771337',
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: Buffer.from(body, 'ascii'),
source_addr: '46701113311',
},
});
const received = await raceWithin(2000, reported);
assert.ok(received);
assert.equal(received.dlr.smsId, smsId);
assert.equal(received.dlr.statusMsg, 'DELIVERED');
assert.equal(received.dlr.receipt?.doneDate, '2509051431');
assert.equal(received.pduObj.params.data_coding, consts.ENCODING.UCS2);
assert.equal(received.pduObj.shortMessageOctets?.toString('latin1'), body);
assert.ok((await delivered).pduObj);
});
test('reassembles a multipart inbound SMS before the sms event', async t => { test('reassembles a multipart inbound SMS before the sms event', async t => {
const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6);
const { peer, session } = await inbound(t); const { peer, session } = await inbound(t);