diff --git a/AGENTS.md b/AGENTS.md index 53552d0..948b95c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ src/ link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout log.ts SmppLog, the logger contract, and silentLog — the default message.ts Encoding detection, splitting, bit counting, SMPP date formatting + message-body.ts Where an inbound body is: short_message, or the message_payload TLV outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning pdu-framer.ts PduFramer: a byte stream cut into complete PDUs @@ -250,8 +251,8 @@ Grouped by what each one constrains. - **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own senders consult them; `session.send({ tlvs })` is passed through as written, because silently stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than - sending them. Only `submit_sm` and `deliver_sm` are policed by bind direction — the only two the - library sends and dispatches by it. + sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the + three the library dispatches by it, of which it sends the first two. - **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer compiles, which never rewired the handlers and so never worked. @@ -314,6 +315,41 @@ Grouped by what each one constrains. MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to the body. +- **A body is read from `message_payload` where `short_message` carries none, and `short_message` + wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability + phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot + carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone + handed the application an empty message + ([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is + the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()` + cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever + it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the + spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the + rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the + TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both, + which discards a message that is almost certainly present twice over, where goal 3 keeps the + traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like + any other segment. + +- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes + it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: + SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and + Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID` + dropped with nothing reported to the application at all. Every command but this one names its own + direction, which is why the bind gate and the dispatch never had to be told which end of the link + they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a + delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a + submission and is read as one, because a report about a message this end never sent is goal 2's + wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by + segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves + `deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs` + rather than a `SessionOptions` entry, so the code that knows which end this is writes it and + nothing else can contradict what the session then binds as. Rejected: grouping the command with + `deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with + `submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one — + `send()` reaches the command raw, and an option choosing which command a message goes out on would + be a second spelling of `sendSms()` whose only difference is which peers accept it. + - **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 @@ -514,7 +550,7 @@ Grouped by what each one constrains. and both exits are logged. - **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.** - `onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()` + `onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()` runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the merges there loses receipts no peer has a reason to send again. They are cleared where the session is over instead. Inbound segments stay in `teardown()`: an 8-bit concatenation reference is the diff --git a/README.md b/README.md index 0a92898..617f140 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,15 @@ to tell the two apart. `esm_class` is what tells them apart; where it names no m 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. +Where the body sits, and which command carried it, changes none of that. An SMSC that leaves +`sm_length` 0 and puts the body in the `message_payload` TLV — SMPP's way of carrying up to 64 KB, +and the only place a `data_sm` has for one — reads exactly like one that fills `short_message`, +concatenated messages and receipts included. A peer that fills both is read from `short_message`. +`data_sm` itself is a peer of both `deliver_sm` and `submit_sm`, and its direction says which: a +client reads one as a delivery, so a message on it arrives as `sms` and a receipt as `dlr`, while a +`server()` session reads one as the submission it is and always hands it to you as `sms`. Either +way it is answered `data_sm_resp`. + 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 `submit_sm_resp` and a decimal `id:` in the receipt, or one of them zero-padded — and the comparison @@ -258,6 +267,11 @@ link the session is: fail with an `err` before anything reaches the wire. - A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one, is answered `ESME_RINVBNDSTS`. +- A `data_sm` carries a message either way, so which end the session is decides what its bind + forbids: a client refuses one on a transmitter bind, a `server()` session on a receiver bind. + `bindAllows('data_sm')` answers for the direction that reaches this session, since the library + sends none. A `Session` you construct yourself is the ESME end, which is what `client()` builds; + a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does. A `transceiver` bind, the default, carries both. `session.send()` stays a low-level passthrough and is not checked, so the raw surface can still put whatever a test or a proxy needs on the wire. @@ -445,7 +459,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. +same field exactly as it arrived. Neither holds the body of a PDU that carried it in the +`message_payload` TLV instead, which a `data_sm` always does — `messageOctets(pduObj)` is the one +answer to which of the two the peer used. The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, and the matching `*ById` maps) and grouped as `defs`. @@ -510,6 +526,10 @@ have worked around any of these, remove the workaround: into a hex string and written back as the ASCII of that string, so every one that made a round trip went out corrupt. They are `Buffer`s in both directions now, so drop any hex encoding of your own. +- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a + `data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost with nothing said. + Both reach the application now — a receipt as `dlr`, answered for you, and a message as `sms` for + you to answer like any other. - Short or malformed PDUs threw out of the codec instead of being reported as a parse failure. - Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter, diff --git a/interop-tests/findings/03-jasmin.md b/interop-tests/findings/03-jasmin.md index cad8a03..4d075cf 100644 --- a/interop-tests/findings/03-jasmin.md +++ b/interop-tests/findings/03-jasmin.md @@ -99,6 +99,13 @@ send it over the connector's session, and inspect the `sms` event at a client bo **Severity.** Medium: silent data loss, not a wire error - the message is fully present on the wire and Jasmin forwards it faithfully; only this library's read of it is incomplete. +**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): the body is read from +`message_payload` where `short_message` carries none. The relay is confirmed on the wire - the +capture's one `sm_length` 0 `deliver_sm` is Jasmin's own, out of `smpps` to our client, carrying the +`0x0424` TLV intact. The reproducer's own fixture was wrong as well as the library: it built the TLV +as Latin-1 while the PDU declared `data_coding` 0, so `_` (GSM 03.38 0x11, Latin-1 0x5F) came back +as `§` even once the body was read. It now encodes the payload the way the PDU says it is written. + ### `sar_*`/UDH segmentation from an upstream SMSC reassembles fine (target 3, MO direction) - not reproduced as a defect here C8's SAR and UDH MO pushes both reassembled into one whole `sms` at our real client. This does not @@ -121,6 +128,12 @@ Our client's `incomingPduObj` sees it arrive; no `dlr` event ever fires. The rec **Severity.** Medium: a real, documented Jasmin configuration (`dlr_pdu = data_sm`) that a receipt depends on silently drops delivery reports, with no error surfaced to the application either. +**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): `data_sm` is classified and +answered exactly as `deliver_sm` is, and the receipt reaches `dlr` naming the id the `submit_sm_resp` +carried, `DELIVERED`. The wire histogram still shows no `data_sm`: `capture` runs +`network_mode: service:jasmin`, so it only ever sees the main instance's namespace, never +`jasmin-datasm`'s. + ### A multi-part MT send deadlocks against Jasmin's serialized per-connector relay - a library/peer interaction, not a wire defect **What happened.** A 2-, 3-, 10-segment GSM or UCS-2 message sent through Jasmin's `smpps` (our real @@ -215,6 +228,11 @@ arrives, with `-` off an id the group is opened with. All four multi-se - Whether the deadlock above is specific to a message requesting a receipt (`registered_delivery` set) or would also occur for a plain multi-segment send with no `dlr` - not isolated separately, since every `C3+C7` case here requests one. +- Whether a real peer accepts a `data_sm_resp` carrying a `message_id`. SMPP 3.4 4.7.2 defines the + field, unlike `deliver_sm_resp`'s, and this library fills it when a `data_sm` carried a message - + but Jasmin only ever sends one as a receipt, which is answered with the field empty, so the filled + case has met no peer. Jasmin FINing over a `deliver_sm_resp` that carried one is the nearest + precedent there is. - Whether Jasmin, given an *upstream* connector that itself defaults to SAR (rather than our library's own UDH), would relay an MT message using SAR instead of preserving our UDH bytes - the 120s-timeout deadlock always intervened before a second segment could be observed on the wire in diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts index f65ff2f..7a3fd83 100644 --- a/interop-tests/jasmin.test.ts +++ b/interop-tests/jasmin.test.ts @@ -11,7 +11,7 @@ import { client } from '../src/client.ts'; import { closeAfter } from '../test/teardown.ts'; import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; -import { splitMessage } from '../src/message.ts'; +import { encodeMessage, splitMessage } from '../src/message.ts'; import { submitSmParams } from '../src/send-sms.ts'; const PEER_HOST = process.env.PEER_HOST ?? 'jasmin'; @@ -246,7 +246,8 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess source_addr: opts.from, }, tlvs: { - message_payload: { tagValue: Buffer.from(opts.message, 'latin1') }, + // The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM. + message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer }, }, }); } @@ -565,20 +566,20 @@ describe('C8 (target 2) - message_payload with sm_length 0', () => { assert.equal(pushed.err, undefined, 'expected Jasmin to accept a message_payload-only deliver_sm from its connector'); - const arrived = await waitFor(() => sms.find(s => s.message === ''), 5000); + const arrived = await waitFor(() => sms.find(s => s.message === text), 5000); await session.close({ signal: AbortSignal.abort() }); - // Confirmed (target 2): Jasmin relays message_payload faithfully (sm_length 0, the real text - // in the TLV) - our own incoming-requests.ts reads short_message only, so the sms that arrives - // here has the right envelope (from/to) but an empty message, never the text carried in the - // TLV. See findings/03-jasmin.md for the reproducer. - assert.ok(arrived, 'expected an sms to arrive (with an empty message, per target 2) for the message_payload push'); + // Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the + // whole body has to reach the application from there. + assert.ok(arrived, 'expected the message_payload body to arrive as the sms text'); + assert.equal(arrived.from, TO); + assert.equal(arrived.to, FROM); }); }); describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => { - test('a receipt thrown as data_sm is not read as a dlr; the raw PDU still arrives', async t => { + test('a receipt thrown as data_sm reaches the dlr event', async t => { await waitForUpstreamSession('datasm'); const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME }); @@ -597,12 +598,16 @@ describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () assert.equal(sent.err, undefined); + const [smsId] = sent.smsIds; const arrived = await waitFor(() => incomingDataSm[0], 15_000); + assert.ok(smsId); assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)'); - await delay(2000); - assert.deepEqual(dlrs, [], 'data_sm is answered ESME_RINVCMDID and never reaches the dlr event (target 4)'); + const dlr = await waitFor(() => dlrs.find(one => one.smsId === smsId), 10_000); + + assert.ok(dlr, `no dlr for the receipt Jasmin threw as data_sm, id ${smsId}`); + assert.equal(dlr.statusMsg, 'DELIVERED'); }); }); diff --git a/src/dlr.ts b/src/dlr.ts index f627231..dca9cd6 100644 --- a/src/dlr.ts +++ b/src/dlr.ts @@ -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); diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index 0bf74cb..dc33691 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -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 = { @@ -104,9 +104,17 @@ export class IncomingRequests { return; } + await this.route(pduObj); + } + + private async route(pduObj: PduObject): Promise { 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 { + private async onDelivery(pduObj: PduObject): Promise { 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 { - 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; } diff --git a/src/index.ts b/src/index.ts index 4812caf..18ac321 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/message-body.ts b/src/message-body.ts new file mode 100644 index 0000000..a244b8f --- /dev/null +++ b/src/message-body.ts @@ -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; +} diff --git a/src/pdu.ts b/src/pdu.ts index 00e7b11..a23454b 100644 --- a/src/pdu.ts +++ b/src/pdu.ts @@ -45,7 +45,10 @@ export type PduObject = { cmdStatusId: number; params: Record; 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; }; diff --git a/src/reassembly.ts b/src/reassembly.ts index 30d849e..7cd6cfe 100644 --- a/src/reassembly.ts +++ b/src/reassembly.ts @@ -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; diff --git a/src/server.ts b/src/server.ts index 0c1d5c1..c5473f6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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)); diff --git a/src/session-options.ts b/src/session-options.ts index f0743ff..2060447 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -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; } diff --git a/src/session.ts b/src/session.ts index 4ba8ba6..0f01ded 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { /** 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 { /** 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. */ diff --git a/test/dlr.test.ts b/test/dlr.test.ts index 86b2072..0c23477 100644 --- a/test/dlr.test.ts +++ b/test/dlr.test.ts @@ -40,6 +40,32 @@ function deliverSm( return pduObj; } +/** SMPP 3.4 5.3.2.32 puts the body in a TLV instead, which is the only place a data_sm has for one. */ +function payloadPdu( + cmdName: 'data_sm' | 'deliver_sm', + body: string, + shortMessage = Buffer.alloc(0), +): PduObject { + const params = { + data_coding: 0, + destination_addr: '46701113311', + esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, + source_addr: '46709771337', + }; + const tlvs = { message_payload: { tagValue: Buffer.from(body, 'latin1') } }; + const built = cmdName === 'deliver_sm' + ? objToPdu({ cmdName, params: { ...params, short_message: shortMessage }, seqNr: 1, tlvs }) + : objToPdu({ cmdName, params, seqNr: 1, tlvs }); + + assert.ok(built.buffer); + + const { pduObj } = pduToObj(built.buffer); + + assert.ok(pduObj); + + return pduObj; +} + describe('parseReceipt()', () => { test('pulls every standard field out of the receipt body', () => { const receipt = parseReceipt(receiptText); @@ -299,6 +325,25 @@ describe('dlrFromPdu()', () => { assert.equal(scheduled.intermediate, true, 'an ordinary receipt reporting a transient state is not final either'); }); + test('reads a receipt the peer carried in message_payload, on deliver_sm and on data_sm', () => { + for (const cmdName of ['data_sm', 'deliver_sm'] as const) { + const dlr = dlrFromPdu(payloadPdu(cmdName, textReceipt)); + + assert.ok(dlr, `expected a ${cmdName} carrying its receipt in message_payload to read as one`); + assert.equal(dlr.smsId, textReceiptId); + assert.equal(dlr.statusMsg, 'DELIVERED'); + assert.equal(dlr.receipt?.stat, 'DELIVRD'); + } + }); + + // The spec has the peer leave sm_length 0 when it uses the TLV, so a filled one is what it meant. + test('reads short_message where the peer filled both fields', () => { + const dlr = dlrFromPdu(payloadPdu('deliver_sm', textReceipt, Buffer.from(receiptText, 'latin1'))); + + assert.ok(dlr); + assert.equal(dlr.smsId, '0195f0c7'); + }); + test('exposes the raw receipt alongside the resolved fields', () => { const dlr = dlrFromPdu(deliverSm(receiptText)); diff --git a/test/message.test.ts b/test/message.test.ts index ec30aa4..886f53e 100644 --- a/test/message.test.ts +++ b/test/message.test.ts @@ -8,6 +8,8 @@ import { smppTime, splitMessage, } from '../src/message.ts'; +// Through the public surface: an application handed a PduObject needs this same answer. +import { messageOctets, objToPdu, pduToObj } from '../src/index.ts'; describe('bitCount()', () => { test('counts GSM characters as seven bits each', () => { @@ -130,6 +132,41 @@ describe('encodeMessage() and decodeMessage()', () => { }); }); +describe('messageOctets()', () => { + /** A data_sm has no short_message field at all, which is the case that has neither. */ + function parsed(short: Buffer | undefined, payload?: Buffer) { + const { buffer } = short === undefined + ? objToPdu({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + }) + : objToPdu({ + cmdName: 'deliver_sm', + params: { destination_addr: '46709771337', short_message: short, source_addr: '46701113311' }, + ...(payload ? { tlvs: { message_payload: { tagValue: payload } } } : {}), + }); + + assert.ok(buffer); + + const { pduObj } = pduToObj(buffer); + + assert.ok(pduObj); + + return pduObj; + } + + test('reads the TLV only where short_message carries nothing', () => { + const short = Buffer.from('in the field'); + const payload = Buffer.from('in the TLV'); + + assert.deepEqual(messageOctets(parsed(Buffer.alloc(0), payload)), payload); + assert.deepEqual(messageOctets(parsed(short, payload)), short); + assert.deepEqual(messageOctets(parsed(short)), short); + assert.deepEqual(messageOctets(parsed(Buffer.alloc(0))), Buffer.alloc(0)); + assert.equal(messageOctets(parsed(undefined)), undefined, 'a PDU with neither carries no body'); + }); +}); + describe('smppDate()', () => { // 0.4.0 used getMonth() without adding one, so January rendered as 00 and every delivery // receipt carried a date a month in the past. diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index c6c2992..98cee81 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -23,7 +23,7 @@ import { Session } from '../src/session.ts'; import { DlrMerger } from '../src/dlr-merger.ts'; import { PduRefusedError } from '../src/pdu-refusal.ts'; import { objToPdu } from '../src/pdu.ts'; -import { checkSessionOptions } from '../src/session-options.ts'; +import { checkSessionOptions, standsInFor } from '../src/session-options.ts'; import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; import { consts } from '../src/defs/constants.ts'; @@ -1449,6 +1449,21 @@ describe('reassembly bounds', () => { }; } + /** The same segment with its body where SMPP 3.4 5.3.2.32 allows it instead. */ + function payloadSegment(reference: number, part: number, total: number): PduObject { + const carried = segment(reference, part, total); + const body = carried.shortMessageOctets; + + assert.ok(body); + + return { + ...carried, + params: { ...carried.params, short_message: Buffer.alloc(0) }, + shortMessageOctets: Buffer.alloc(0), + tlvs: { message_payload: { tagId: 0x0424, tagName: 'message_payload', tagValue: body } }, + }; + } + function collect( reassembler: Reassembler, reference: number, @@ -1504,6 +1519,25 @@ describe('reassembly bounds', () => { assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost'); }); + // The two addresses are 22 octets, so only the 14 the TLV carries can overrun a cap of 30. + test('counts a body carried in message_payload against the octet cap', () => { + function collectPayload(maxOctets: number): Collected { + const reassembler = new Reassembler({ + log: silentLog, + max: 10, + maxOctets, + now: () => 0, + onLost: () => undefined, + timeout: 60_000, + }); + + return reassembler.collect(payloadSegment(9, 1, 2), { part: 1, reference: 9, total: 2 }); + } + + assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later'); + assert.equal(collectPayload(40).kept, true); + }); + // The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one. test('reports the answered segments of a group that overruns the cap mid-message', () => { const lost: LostGroup[] = []; @@ -1730,6 +1764,17 @@ describe('the status a refused segment is answered with', () => { assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable'), 'ESME_RINVESMCLASS'); assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable'), 'ESME_RINVESMCLASS'); }); + + // Which command that is, for the one that travels both ways, is what the end it arrived at says. + test('reads a data_sm as the command its direction makes it', () => { + assert.equal(standsInFor('data_sm', 'esme'), 'deliver_sm'); + assert.equal(standsInFor('data_sm', 'smsc'), 'submit_sm'); + assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm'); + assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm'); + assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link'); + assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full'), 'ESME_RMSGQFUL'); + assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full'), 'ESME_RX_T_APPN'); + }); }); describe('a peer that sends the next segment only once the last one is answered', () => { diff --git a/test/session.test.ts b/test/session.test.ts index 1d6e100..4e957d5 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -514,6 +514,58 @@ describe('bind direction', () => { assert.ok(report.err instanceof Error); assert.match(report.err.message, /transmitter-bound/); }); + + // data_sm carries a message either way, so which end this is decides which way it may travel. + test('refuses a data_sm sent to a peer that bound as a transmitter', async t => { + const smpp = await startServer(t); + const bound = once(resolve => { smpp.on('session', resolve); }); + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); + + const peer = await bound; + const sent = await peer.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + tlvs: { message_payload: { tagValue: Buffer.from('nope') } }, + }); + + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdName, 'data_sm_resp'); + assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS'); + }); + + test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => { + const smpp = await startServer(t); + + smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp())); + + const receiving = await connect(t, smpp, { bindType: 'receiver' }); + + assert.ok(receiving.session); + + const refused = await receiving.session.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + tlvs: { message_payload: { tagValue: Buffer.from('nope') } }, + }); + + assert.ok(refused.pduObj); + assert.equal(refused.pduObj.cmdStatus, 'ESME_RINVBNDSTS'); + + const sending = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(sending.session); + + const carried = await sending.session.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + tlvs: { message_payload: { tagValue: Buffer.from('a submission the bind carries') } }, + }); + + assert.ok(carried.pduObj); + assert.equal(carried.pduObj.cmdStatus, 'ESME_ROK'); + }); }); describe('sending', () => { @@ -707,6 +759,199 @@ describe('receiving', () => { assert.equal(sms.smsId, 'inbound-id', 'the id the application chose is still its own handle'); }); + // SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty. + test('reads an inbound message the peer carried in message_payload', async t => { + const { peer, session } = await inbound(t); + const incoming = once(resolve => { session.on('sms', resolve); }); + const text = 'the whole body, carried in the TLV'; + const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + short_message: Buffer.alloc(0), + source_addr: '46701113311', + }, + tlvs: { message_payload: { tagValue: Buffer.from(text, 'latin1') } }, + }); + const sms = await raceWithin(2000, incoming); + + assert.ok(sms, 'a body the peer put in message_payload is still a message'); + assert.equal(sms.message, text); + assert.equal(sms.from, '46701113311'); + assert.equal(sms.to, '46709771337'); + + await sms.sendResp(); + + const answered = await delivered; + + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); + }); + + test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => { + const { peer, session } = await inbound(t); + const incoming = once(resolve => { session.on('sms', resolve); }); + const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806'; + const delivered = peer.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + tlvs: { message_payload: { tagValue: Buffer.from('a message carried on the data command', 'latin1') } }, + }); + const sms = await raceWithin(2000, incoming); + + assert.ok(sms, 'data_sm is a peer of deliver_sm, not a command to refuse'); + assert.equal(sms.message, 'a message carried on the data command'); + assert.equal(sms.from, '46701113311'); + + await sms.sendResp({ smsId }); + + const answered = await delivered; + + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdName, 'data_sm_resp'); + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); + // SMPP 3.4 4.7.2 gives data_sm_resp a message_id, where 4.6.2 leaves deliver_sm_resp's unused. + assert.equal(paramText(answered.pduObj.params.message_id), smsId); + }); + + test('hands a client a receipt carried on data_sm as a dlr', async t => { + const { peer, session } = await inbound(t); + const reported = once(resolve => { session.on('dlr', resolve); }); + const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77'; + let messages = 0; + + session.on('sms', () => { messages++; }); + + const delivered = peer.send({ + cmdName: 'data_sm', + params: { + destination_addr: '46709771337', + esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, + source_addr: '46701113311', + }, + tlvs: { + message_payload: { + tagValue: Buffer.from(`id:${smsId} sub:001 dlvrd:001 stat:DELIVRD err:000 text:`, 'latin1'), + }, + }, + }); + const dlr = await raceWithin(2000, reported); + + assert.ok(dlr, 'a receipt thrown as data_sm is still a receipt'); + assert.equal(dlr.smsId, smsId); + assert.equal(dlr.statusMsg, 'DELIVERED'); + assert.equal(messages, 0); + + const answered = await delivered; + + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdName, 'data_sm_resp'); + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); + }); + + // At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends. + test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => { + const smpp = await startServer(t); + const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:'; + const messages: Sms[] = []; + const reports: Dlr[] = []; + + smpp.on('session', peer => { + peer.on('dlr', dlr => { reports.push(dlr); }); + peer.on('sms', sms => { + messages.push(sms); + void sms.sendResp(); + }); + }); + + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); + + const submitted = await session.send({ + cmdName: 'data_sm', + params: { + destination_addr: '46709771337', + esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, + source_addr: '46701113311', + }, + tlvs: { message_payload: { tagValue: Buffer.from(body, 'latin1') } }, + }); + + assert.ok(submitted.pduObj); + assert.equal(submitted.pduObj.cmdStatus, 'ESME_ROK'); + assert.notEqual(paramText(submitted.pduObj.params.message_id), ''); + assert.equal(messages[0]?.message, body); + assert.deepEqual(reports, [], 'an ESME submitting is never the network reporting'); + }); + + // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it. + test('refuses a data_sm segment a server has no room for with the submit code', async t => { + // The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 30. + const smpp = await startServer(t, { maxOctets: 30 }); + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); + + const segment = splitMessage('one of two, too big to hold. '.repeat(12), { reference: 0x5C })[0]; + + assert.ok(segment); + + const refused = await session.send({ + cmdName: 'data_sm', + params: { + destination_addr: '46709771337', + esm_class: consts.ESM_CLASS.UDH_INDICATOR, + source_addr: '46701113311', + }, + tlvs: { message_payload: { tagValue: segment } }, + }); + + assert.ok(refused.pduObj); + assert.equal(refused.pduObj.cmdName, 'data_sm_resp'); + assert.equal(refused.pduObj.cmdStatus, 'ESME_RMSGQFUL'); + }); + + test('reassembles a concatenated message whose segments arrived in message_payload', async t => { + const { peer, session } = await inbound(t); + const incoming = once(resolve => { session.on('sms', resolve); }); + const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6); + const segments = splitMessage(text, { reference: 0x3B }); + + assert.ok(segments.length > 1, 'the fixture must need more than one segment'); + + const answers: PduObject[] = []; + + for (const segment of segments) { + const sent = await peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + esm_class: consts.ESM_CLASS.UDH_INDICATOR, + short_message: Buffer.alloc(0), + source_addr: '46701113311', + }, + tlvs: { message_payload: { tagValue: segment } }, + }); + + assert.ok(sent.pduObj); + answers.push(sent.pduObj); + } + + const sms = await raceWithin(2000, incoming); + + assert.ok(sms, 'the segments join into one message wherever their bodies were carried'); + assert.equal(sms.message, text); + assert.equal(sms.answeredOnArrival, true); + assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), + answers.map(() => ''), + 'SMPP 3.4 4.6.2 leaves deliver_sm_resp\'s message_id unused, segment by segment too', + ); + assert.deepEqual(await sms.sendResp(), {}); + }); + test('hands a client a report as a dlr rather than as an sms', async t => { const { peer, session } = await inbound(t); const reported = once(resolve => { session.on('dlr', resolve); });