Read a body from message_payload and accept data_sm (#84)

* Regression tests for a body in message_payload and for data_sm

* Read a body from message_payload and accept data_sm

* Assert the fixed message_payload and data_sm behaviour against Jasmin

* Record the message_payload and data_sm fixes in the Jasmin findings

* Read an inbound data_sm as the direction it travelled, and export messageOctets

* Refuse a segment with the code its stand-in command defines

* Name the stand-in the refusal status is read from
This commit is contained in:
2026-09-06 05:59:27 +02:00
committed by GitHub
parent 5b7b563dc2
commit 184d1dc7af
17 changed files with 552 additions and 41 deletions
+3 -2
View File
@@ -4,6 +4,7 @@ import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts';
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
import { encodings } from './defs/encodings.ts';
import { messageOctets } from './message-body.ts';
import { normaliseSmsId } from './sms-id.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { udhLength } from './udh.ts';
@@ -157,7 +158,7 @@ function messageType(pduObj: PduObject): MessageType {
/** SMPP 3.4 Appendix B makes a receipt fixed text, so its octets are read as octets, not decoded. */
function receiptBody(pduObj: PduObject): string {
const octets = pduObj.shortMessageOctets;
const octets = messageOctets(pduObj);
if (octets === undefined) return paramText(pduObj.params.short_message);
@@ -203,7 +204,7 @@ function receiptStatus(
return { statusId: tlvState, statusMsg: isMessageState(named) ? named : scraped };
}
/** The delivery report a deliver_sm carries, or nothing when it carries a message instead. */
/** The delivery report a deliver_sm or data_sm carries, or nothing where it carries a message. */
export function dlrFromPdu(pduObj: PduObject, format: SmsIdFormat = {}): Dlr | undefined {
const type = messageType(pduObj);
+24 -9
View File
@@ -1,4 +1,3 @@
import type { CommandName } from './defs/commands.ts';
import type { DlrMerger } from './dlr-merger.ts';
import type { ErrorName } from './defs/errors.ts';
import type { LostGroup, Refusal } from './reassembly.ts';
@@ -10,19 +9,20 @@ import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts';
import { HeldMessages } from './held-messages.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults } from './session-options.ts';
import { bindCommands, defaults, standsInFor } from './session-options.ts';
import { concatInfo } from './udh.ts';
import { hasUdh } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { dlrFromPdu } from './dlr.ts';
import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { respIdParams, segmentId } from './sms-id.ts';
/** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */
export function refusedSegmentStatus(cmdName: CommandName, refusal: Refusal): ErrorName {
export function refusedSegmentStatus(carriedAs: string, refusal: Refusal): ErrorName {
if (refusal === 'unplaceable') return 'ESME_RINVESMCLASS';
return cmdName === 'deliver_sm' ? 'ESME_RX_T_APPN' : 'ESME_RMSGQFUL';
return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN';
}
const lostReasons: Record<LostGroup['reason'], string> = {
@@ -104,9 +104,17 @@ export class IncomingRequests {
return;
}
await this.route(pduObj);
}
private async route(pduObj: PduObject): Promise<void> {
switch (pduObj.cmdName) {
case 'data_sm':
case 'deliver_sm':
await this.onDeliverSm(pduObj);
// A data_sm at the SMSC end is a submission, and a submission is never a report.
await (this.carriedAs(pduObj) === 'submit_sm'
? this.onMessage(pduObj)
: this.onDelivery(pduObj));
break;
case 'enquire_link':
await this.session.sendReturn(pduObj);
@@ -161,8 +169,12 @@ export class IncomingRequests {
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
}
private carriedAs(pduObj: PduObject): string {
return standsInFor(pduObj.cmdName, this.session.linkEnd);
}
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
private async onDeliverSm(pduObj: PduObject): Promise<void> {
private async onDelivery(pduObj: PduObject): Promise<void> {
const dlr = dlrFromPdu(pduObj, this.smsIdFormat);
if (!dlr) {
@@ -185,9 +197,9 @@ export class IncomingRequests {
* one request at a time never sends the second segment until the first has been answered.
*/
private async onMessage(pduObj: PduObject): Promise<void> {
const message = pduObj.params.short_message;
const message = messageOctets(pduObj);
const carriesUdh = hasUdh(paramNumber(pduObj.params.esm_class, 0));
const concat = carriesUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
const concat = carriesUdh && message ? concatInfo(message) : undefined;
if (!concat) {
this.emitSms([pduObj]);
@@ -198,7 +210,10 @@ export class IncomingRequests {
const collected = this.reassembler.collect(pduObj, concat);
if (!collected.kept) {
await this.session.sendReturn(pduObj, refusedSegmentStatus(pduObj.cmdName, collected.refusal));
await this.session.sendReturn(
pduObj,
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal),
);
return;
}
+1
View File
@@ -30,6 +30,7 @@ export {
} from './message.ts';
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
export { messageOctets } from './message-body.ts';
export { concatInfo } from './udh.ts';
export { PduFramer } from './pdu-framer.ts';
export { uuidv7 } from './uuid.ts';
+17
View File
@@ -0,0 +1,17 @@
import type { PduObject } from './pdu.ts';
/**
* The user data, wherever the peer put it. SMPP 3.4 5.3.2.32 carries up to 64 KB in
* `message_payload` with `sm_length` 0, and a `data_sm` has nowhere else to put a body at all.
* A filled `short_message` is the peer using the mandatory field, so it wins over a TLV the spec
* only allows in its place.
*/
export function messageOctets(pduObj: PduObject): Buffer | undefined {
const shortMessage = pduObj.shortMessageOctets;
if (shortMessage !== undefined && shortMessage.length > 0) return shortMessage;
const payload = pduObj.tlvs.message_payload?.tagValue;
return Buffer.isBuffer(payload) ? payload : shortMessage;
}
+4 -1
View File
@@ -45,7 +45,10 @@ export type PduObject = {
cmdStatusId: number;
params: Record<string, ParamValue>;
seqNr: number;
/** short_message as it arrived, whatever data_coding turned `params.short_message` into. */
/**
* short_message as it arrived, whatever data_coding turned `params.short_message` into. A body
* the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
*/
shortMessageOctets: Buffer | undefined;
tlvs: Record<string, Tlv>;
};
+9 -8
View File
@@ -5,6 +5,7 @@ import type { SmppLog } from './log.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { uuidv7 } from './uuid.ts';
@@ -108,15 +109,15 @@ export function decodeSegments(pduObjs: PduObject[]): string {
let message = '';
for (const pduObj of pduObjs) {
const part = pduObj.params.short_message;
const part = messageOctets(pduObj);
message += Buffer.isBuffer(part)
? decodeMessage(
part,
paramNumber(pduObj.params.data_coding, 0),
paramNumber(pduObj.params.esm_class, 0),
).message
: paramText(part);
if (part === undefined) continue;
message += decodeMessage(
part,
paramNumber(pduObj.params.data_coding, 0),
paramNumber(pduObj.params.esm_class, 0),
).message;
}
return message;
+1
View File
@@ -241,6 +241,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
systemId: options.systemId ?? defaults.systemId,
});
session.linkEnd = 'smsc';
server.sessions.add(session);
session.on('close', () => server.sessions.delete(session));
+22 -3
View File
@@ -32,6 +32,9 @@ export const bindCommands: readonly string[] = [
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */
export type LinkEnd = 'esme' | 'smsc';
export function bindTypeFromCommand(cmdName: string): BindType | undefined {
if (cmdName === 'bind_receiver') return 'receiver';
if (cmdName === 'bind_transceiver') return 'transceiver';
@@ -40,14 +43,30 @@ export function bindTypeFromCommand(cmdName: string): BindType | undefined {
return undefined;
}
/**
* Which message-carrying command an inbound one stands in for. Every command but `data_sm` names
* its own direction; that one travels either way, so the end it arrived at is what says.
*/
export function standsInFor(cmdName: string, linkEnd: LinkEnd): string {
if (cmdName !== 'data_sm') return cmdName;
return linkEnd === 'smsc' ? 'submit_sm' : 'deliver_sm';
}
/**
* Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
* transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
* has not bound carries everything, since nothing has declared a direction yet.
*/
export function bindCarries(bindType: BindType | undefined, cmdName: string): boolean {
if (bindType === 'receiver') return cmdName !== 'submit_sm';
if (bindType === 'transmitter') return cmdName !== 'deliver_sm';
export function bindCarries(
bindType: BindType | undefined,
cmdName: string,
linkEnd: LinkEnd,
): boolean {
const carried = standsInFor(cmdName, linkEnd);
if (bindType === 'receiver') return carried !== 'submit_sm';
if (bindType === 'transmitter') return carried !== 'deliver_sm';
return true;
}
+4 -2
View File
@@ -3,7 +3,7 @@ import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.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, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts';
@@ -54,6 +54,8 @@ export class Session extends EventEmitter<SessionEvents> {
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
boundAs: BindType | undefined = undefined;
/** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
linkEnd: LinkEnd = 'esme';
loggedIn = false;
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined;
@@ -154,7 +156,7 @@ export class Session extends EventEmitter<SessionEvents> {
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */
bindAllows(cmdName: string): boolean {
return bindCarries(this.boundAs, cmdName);
return bindCarries(this.boundAs, cmdName, this.linkEnd);
}
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */