diff --git a/AGENTS.md b/AGENTS.md index e28ba33..1f8db30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,7 @@ src/ 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 + 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 pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort reassembly.ts Reassembler: capped, expiring multipart groups @@ -96,7 +97,7 @@ src/ send-window.ts SendWindow: the maxOutstanding semaphore 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 - 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 uuid.ts uuidv7() — the ids the library generates for messages 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 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 `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 diff --git a/README.md b/README.md index 51700fb..77a9733 100644 --- a/README.md +++ b/README.md @@ -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 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 -`id:` and `stat:` receipt fields. An intermediate delivery notification is the SMSC reporting as -well, not an inbound message. +`id:` and `stat:` receipt fields. That body is read as text whatever `data_coding` the receipt +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()` 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`, `types`, and the matching `*ById` maps) and grouped as `defs`. diff --git a/interop-tests/findings/02-smppsim.md b/interop-tests/findings/02-smppsim.md index 8916b5d..044fdc9 100644 --- a/interop-tests/findings/02-smppsim.md +++ b/interop-tests/findings/02-smppsim.md @@ -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) | | 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, 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 | | 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 | @@ -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 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 **What happened.** `client()` performs the socket connect and the initial bind directly, not diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts index acbac01..f2e9fb3 100644 --- a/interop-tests/smppsim.test.ts +++ b/interop-tests/smppsim.test.ts @@ -4,7 +4,6 @@ import type { Dlr } from '../src/dlr.ts'; import type { EncodingName } from '../src/defs/encodings.ts'; import type { MessageDlr } from '../src/dlr-merger.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 { Sms } from '../src/sms.ts'; import { client } from '../src/client.ts'; @@ -164,11 +163,20 @@ async function sendUntilComplete( } 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: 2, label: '2-segment GSM with extension chars', message: gsmFiller(200) }, { expectedSegments: 3, label: '3-segment GSM with extension chars', message: gsmFiller(400) }, { 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) { @@ -182,7 +190,13 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { const dlrs = collectDlrs(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); @@ -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). 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.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); }); } - - 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', () => { diff --git a/src/defs/commands.ts b/src/defs/commands.ts index cd37652..2850dc7 100644 --- a/src/defs/commands.ts +++ b/src/defs/commands.ts @@ -265,3 +265,12 @@ export function commandNameById(id: number): CommandName | 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; +} diff --git a/src/dlr.ts b/src/dlr.ts index 7b6a417..f627231 100644 --- a/src/dlr.ts +++ b/src/dlr.ts @@ -2,10 +2,11 @@ import type { MessageState } from './defs/constants.ts'; import type { ParamValue } from './defs/types.ts'; import type { PduObject } from './pdu.ts'; import type { SmsIdFormat } from './sms-id.ts'; -import { consts, constsById, messageTypeOf } from './defs/constants.ts'; -import { decodeMessage } from './message.ts'; +import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts'; +import { encodings } from './defs/encodings.ts'; import { normaliseSmsId } from './sms-id.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 @@ -154,17 +155,17 @@ function messageType(pduObj: PduObject): MessageType { 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 { - 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( - message, - paramNumber(pduObj.params.data_coding, 0), - paramNumber(pduObj.params.esm_class, 0), - ).message; + const body = hasUdh(paramNumber(pduObj.params.esm_class, 0)) + ? octets.subarray(udhLength(octets)) + : octets; + + return encodings.LATIN1.decode(body); } function receiptId( diff --git a/src/index.ts b/src/index.ts index 3cf22e5..a2a3ade 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,13 +12,14 @@ export { types } from './defs/types.ts'; export { isCommand, isResp, - maxPduLength, maxSeqNr, objToPdu, pduReturn, pduToObj, } from './pdu.ts'; +export { maxPduLength } from './pdu-refusal.ts'; + export { bitCount, decodeMessage, diff --git a/src/message.ts b/src/message.ts index 792aca5..82b0834 100644 --- a/src/message.ts +++ b/src/message.ts @@ -1,7 +1,8 @@ import type { Result } from './result.ts'; import type { EncodingName } from './defs/encodings.ts'; -import { hasUdh } from './defs/constants.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. */ const singleMessageBits = 1120; @@ -37,11 +38,11 @@ export function decodeMessage( return { message: encodings[encoding].decode(buffer), udh: undefined }; } - const udhLength = (buffer[0] ?? 0) + 1; + const headerLength = udhLength(buffer); return { - message: encodings[encoding].decode(buffer.subarray(udhLength)), - udh: buffer.subarray(0, udhLength), + message: encodings[encoding].decode(buffer.subarray(headerLength)), + udh: buffer.subarray(0, headerLength), }; } diff --git a/src/pdu-framer.ts b/src/pdu-framer.ts index 8719f8f..1b23dea 100644 --- a/src/pdu-framer.ts +++ b/src/pdu-framer.ts @@ -1,5 +1,5 @@ import type { Result } from './result.ts'; -import { framingRefusal } from './pdu.ts'; +import { framingRefusal } from './pdu-refusal.ts'; /** * Cuts a byte stream into whole PDUs. diff --git a/src/pdu-refusal.ts b/src/pdu-refusal.ts new file mode 100644 index 0000000..45b7a2a --- /dev/null +++ b/src/pdu-refusal.ts @@ -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; + +/** 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], + }; +} diff --git a/src/pdu-transport.ts b/src/pdu-transport.ts index bcf35f8..966cdee 100644 --- a/src/pdu-transport.ts +++ b/src/pdu-transport.ts @@ -3,7 +3,8 @@ import type { SmppLog } from './log.ts'; import type { Socket } from 'node:net'; import type { VoidResult } from './result.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 = { log: SmppLog; diff --git a/src/pdu.ts b/src/pdu.ts index cd05832..00e7b11 100644 --- a/src/pdu.ts +++ b/src/pdu.ts @@ -1,9 +1,11 @@ import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; import type { ErrorName } from './defs/errors.ts'; import type { ParamValue } from './defs/types.ts'; +import type { PduHeader } from './pdu-refusal.ts'; import type { Result, VoidResult } from './result.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 { decodeMessage, encodeMessage } from './message.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. */ 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 = { /** Resolved from the record key; pass it for a tag the TLV table does not define. */ tagId?: number | undefined; @@ -55,60 +45,11 @@ export type PduObject = { cmdStatusId: number; params: Record; seqNr: number; + /** short_message as it arrived, whatever data_coding turned `params.short_message` into. */ + shortMessageOctets: Buffer | undefined; tlvs: Record; }; -/** 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; - -/** 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; export function isResp(pduObj: Pick): boolean { @@ -391,11 +332,11 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea const params = read.params; 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. - if (Buffer.isBuffer(message) && !hasUdh(esmClass)) { - params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message; + if (octets && !hasUdh(paramNumber(params.esm_class, 0))) { + params.short_message = decodeMessage(octets, paramNumber(params.data_coding, 0)).message; } return { @@ -408,6 +349,7 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea cmdStatusId, params, seqNr, + shortMessageOctets: octets, tlvs: parsed.tlvs, }, }; diff --git a/src/reassembly.ts b/src/reassembly.ts index efc5e2e..c7b390d 100644 --- a/src/reassembly.ts +++ b/src/reassembly.ts @@ -39,7 +39,12 @@ function detach(pduObj: PduObject): PduObject { : 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. diff --git a/src/session.ts b/src/session.ts index 203b281..fed5090 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,7 +1,8 @@ import type { ErrorName } from './defs/errors.ts'; import type { MessageDlr } from './dlr-merger.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 { Result, VoidResult } from './result.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 { optionalParamsMinVersion } from './defs/constants.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 { submitSms, unsent } from './send-sms.ts'; import { ConcatReference } from './udh.ts'; diff --git a/src/udh.ts b/src/udh.ts index 1b6c399..30ab9f1 100644 --- a/src/udh.ts +++ b/src/udh.ts @@ -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 = { part: number; reference: number; diff --git a/test/dlr.test.ts b/test/dlr.test.ts index 80b6260..86b2072 100644 --- a/test/dlr.test.ts +++ b/test/dlr.test.ts @@ -2,19 +2,26 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; import { consts } from '../src/defs/constants.ts'; import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts'; +import { encodeMessage } from '../src/message.ts'; import { objToPdu, pduToObj } 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'; +/** 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( message: Buffer | string, tlvs?: Record, esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT, + dataCoding = 0, ): PduObject { const { buffer } = objToPdu({ cmdName: 'deliver_sm', params: { + data_coding: dataCoding, destination_addr: '46701113311', esm_class: esmClass, short_message: message, @@ -149,8 +156,8 @@ describe('dlrFromPdu()', () => { assert.equal(dlr.intermediate, false); }); - // pduToObj leaves a UDH-carrying short_message a buffer, so the body needs decoding before it - // can be read at all — and the message type sits under the UDH indicator in the same octet. + // The message type sits under the UDH indicator in the same octet, and the header has to come + // off the body before any of it can be read. test('reads the body of a receipt that carries a UDH', () => { const udh = Buffer.from([0x05, 0x00, 0x03, 0x2a, 0x01, 0x01]); const body = Buffer.concat([udh, Buffer.from(receiptText, 'ascii')]); @@ -158,6 +165,7 @@ describe('dlrFromPdu()', () => { body, undefined, consts.ESM_CLASS.MC_DELIVERY_RECEIPT | consts.ESM_CLASS.UDH_INDICATOR, + consts.ENCODING.UCS2, )); assert.ok(dlr); @@ -165,6 +173,68 @@ describe('dlrFromPdu()', () => { 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. 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 } })); diff --git a/test/pdu.test.ts b/test/pdu.test.ts index 8891c73..3ffb7f3 100644 --- a/test/pdu.test.ts +++ b/test/pdu.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; 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): Buffer { 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', () => { const dest = { dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46709771337' }; diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 5b1f322..0dbeff2 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -83,6 +83,7 @@ function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject cmdStatusId: 0, params: { destination_addr: '46709771337', short_message: 'held', source_addr: '46701113311' }, seqNr, + shortMessageOctets: undefined, tlvs: {}, }; } @@ -313,6 +314,7 @@ describe('sendSms()', () => { cmdStatusId: errors[status], params: { message_id: messageId }, seqNr, + shortMessageOctets: undefined, tlvs: {}, }; } @@ -1153,6 +1155,7 @@ describe('sendDlr()', () => { describe('reassembly bounds', () => { function segment(reference: number, part: number, total: number): PduObject { const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]); + const body = Buffer.concat([udh, Buffer.from('fragment')]); return { cmdId: 0x00000004, @@ -1164,10 +1167,11 @@ describe('reassembly bounds', () => { data_coding: 0, destination_addr: '46709771337', esm_class: 0x40, - short_message: Buffer.concat([udh, Buffer.from('fragment')]), + short_message: body, source_addr: '46701113311', }, seqNr: part, + shortMessageOctets: body, tlvs: {}, }; } diff --git a/test/session.test.ts b/test/session.test.ts index f9c9813..3ed39c6 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -168,6 +168,7 @@ function enquireLink(seqNr: number): PduObject { cmdStatusId: 0, params: {}, seqNr, + shortMessageOctets: undefined, tlvs: {}, }; } @@ -749,6 +750,35 @@ describe('receiving', () => { 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 => { const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); const { peer, session } = await inbound(t);