From 18de565aad464719ade0ddf97d78c70f0a8655ae Mon Sep 17 00:00:00 2001 From: Lillem4n Date: Sun, 6 Sep 2026 21:03:14 +0200 Subject: [PATCH] Reassemble a concatenated message segmented with the sar_* TLVs (#91) * Regression tests for inbound sar_* concatenation * Reassemble a concatenated message segmented with the sar_* TLVs * Record the sar_* fix in the jasmin and Java-client findings * Name the sar_* TLVs in the refusal, and separate group keys the addresses cannot forge * Name the field a refused segment got wrong, and key groups on a separator no address holds * Bound the new session tests, and derive Concat from the UDH fields --- AGENTS.md | 35 ++- README.md | 17 +- interop-tests/findings/03-jasmin.md | 5 + interop-tests/findings/05-java-clients.md | 12 +- interop-tests/jasmin.test.ts | 8 +- interop-tests/jsmpp.test.ts | 18 +- src/concat.ts | 40 ++++ src/incoming-requests.ts | 24 +- src/index.ts | 2 + src/reassembly.ts | 22 +- test/session-extras.test.ts | 261 ++++++++++++++++++---- test/session.test.ts | 240 +++++++++++++++++++- 12 files changed, 595 insertions(+), 89 deletions(-) create mode 100644 src/concat.ts diff --git a/AGENTS.md b/AGENTS.md index 5fc6cb2..3870ed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ src/ server.ts server() -> { err, server }, server owns the listener + close() session.ts Session: the socket's life, dispatch, events, and the collaborators below sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr) + concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs dlr.ts Delivery receipts: text and TLV parsing, receipt status codes dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr error-from.ts errorFrom(): whatever was thrown or rejected, as an Error @@ -156,6 +157,7 @@ naming the behaviour. | Alphanumeric sender TON | `sendSms` hardcodes `source_addr_ton` to 1 (international) even for alphanumeric senders, which require TON 5 | | Text-only DLRs refused | `deliver_sm` without both `message_state` and `receipted_message_id` TLVs is rejected with `ESME_RINVTLVSTREAM`, so Kannel-style receipts are unusable | | Unbounded reassembly | Incomplete long-SMS groups are capped by nothing and swept only when other traffic arrives, after 24 hours | +| `sar_*` segmentation unread | `session.js` reassembles on the UDH alone, so a message segmented with `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` — SMPP 3.4's other spelling, and Jasmin's documented default — reaches the application one fragment per segment | | Dead DLR aggregation | `longSmsDlrs` is allocated to merge per-segment receipts and then never used | | Trailing NULL truncation | `types.buffer.size()` subtracts one whenever the value's last octet is `0x00`, so the PDU is allocated one octet short while `sm_length` still reports the full length. Any UCS2 message ending in a character like U+4E00 or U+3000 goes out corrupt | | Dormant filters | `defs.filters` is declared on commands and TLVs but never invoked anywhere | @@ -330,6 +332,33 @@ Grouped by what each one constrains. traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like any other segment. +- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none, + and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from + the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make + `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says, + Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed + the application one `sms` per fragment + ([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). + `concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to + where a body is, and both are exported for the same reason: an application on the low-level + surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the + reference, so the key is two tokens the reassembler joins and interprets neither of, and so the + refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL` + for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would + assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num` + is 16 and neither counts the other's messages; the two UDH widths share a space because they are + one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names + the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps + the change additive for every message that reassembled before, and leaves the library nothing to + guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a + gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the + references are not comparable at all, and where the parts are, the UDH is still what the message + is assembled by, so the report would name a failure the application cannot act on. Accepted: a + peer that switches spelling mid-message now has two groups that expire rather than fragments that + arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()` + goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second + spelling of one message whose only difference is which peers accept it. + - **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 @@ -516,8 +545,8 @@ Grouped by what each one constrains. untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` answers every segment it will not carry rather than leaving it unanswered, which is the same stall - in miniature: `ESME_RINVESMCLASS` where the UDH belongs to no group, `ESME_RMSGQFUL` where the - segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: + in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL` + where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: answering every segment but the one that completes the group, which leaves the peer holding some segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment, @@ -608,7 +637,7 @@ Grouped by what each one constrains. `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 + is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the peer's own counter, so a half-arrived group kept across a drop would take a later message's segments as readily as the rest of its own, and goal 2 will not hand the application a message assembled that way. What goes there is traffic already answered, which is why each group reaches diff --git a/README.md b/README.md index 0d5ecff..942907d 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,12 @@ client reads one as a delivery, so a message on it arrives as `sms` and a receip `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`. +Nor does the way a peer ties a long message's segments together. A user data header at the start of +the body and the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum` TLVs are the two +spellings of the same thing, and either reassembles into one `sms`. A header that numbers the +segment is what a PDU carrying both is read from, and the two reference numbers are counters of +their own — the same number in each is two different messages. + 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 @@ -228,8 +234,8 @@ await smpp.close(); // stop listening, then drain and close every live sess A message that arrived in several segments was already answered when you see it: each segment is answered as it lands, because a relaying SMSC will not send the next one until the last is answered. -That answer is `ESME_ROK` unless the segment's header names no message this session can join, which -refuses it, or the reassembly buffer is full, which asks the SMSC to keep it and try again. +That answer is `ESME_ROK` unless the segment numbers itself into no message this session can join, +which refuses it, or the reassembly buffer is full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message you are holding was answered that way — a segment count cannot, since a peer may number a concatenated message one part of one. The id was fixed with the first segment, so `sendResp()` there only says you are done with the message, and returns an `err` for an `smsId` or a @@ -507,7 +513,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. 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. +answer to which of the two the peer used. `concatOf(pduObj)` is the same for concatenation: the +`part`, `total` and `reference` a PDU declares, and the `spelling` — `'udh'` or `'sar'` — that +carried them, or `undefined` where the PDU is a whole message. The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, and the matching `*ById` maps) and grouped as `defs`. @@ -576,6 +584,9 @@ have worked around any of these, remove the workaround: `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. +- A long message whose segments were tied together by the `sar_msg_ref_num`, `sar_total_segments` + and `sar_segment_seqnum` TLVs rather than by a user data header was never reassembled, so each + segment arrived as its own message. Both spellings reassemble now. - 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 4d075cf..f006c20 100644 --- a/interop-tests/findings/03-jasmin.md +++ b/interop-tests/findings/03-jasmin.md @@ -115,6 +115,11 @@ sent SAR MO unprompted), it confirms only that pushing SAR/UDH-tagged `deliver_s directly over the connector session, reassembles correctly on the way out through `smpps` - Jasmin does not re-segment or otherwise disturb an already-short single PDU in transit. +**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91), where a second peer did reproduce +it (`findings/05-java-clients.md`): reassembly now reads the `sar_*` TLVs as well as the UDH. C8's +SAR scenario no longer accepts fragments as an outcome - it asserts one whole `sms` and no segment +arriving on its own - and a rerun is 19/19 with no malformed frame and no expert error. + ### `data_sm` is refused (target 4) - confirmed against a real peer **What happened.** With `jasmin-datasm`'s `[dlr-thrower] dlr_pdu = data_sm`, a receipt requested via diff --git a/interop-tests/findings/05-java-clients.md b/interop-tests/findings/05-java-clients.md index 32d6ecd..b79e614 100644 --- a/interop-tests/findings/05-java-clients.md +++ b/interop-tests/findings/05-java-clients.md @@ -75,7 +75,7 @@ an independent dissector agreeing is the expected outcome, not a surprise. | S2 UDH 8-bit (targets 2, 5) | pass | `jsmpp.test.ts` "UDH, 8-bit reference": one reassembled `sms`, segments answered `-1`/`-2` | | S2 UDH 16-bit (target 5) | pass | `jsmpp.test.ts` "UDH, 16-bit reference": also reassembled - confirms both widths are read | | S2 `message_payload` (target 2) | pass | `jsmpp.test.ts` "message_payload: one sms, the full text" | -| S2 `sar_*` (target 3) | defect confirmed, second peer | `jsmpp.test.ts` "sar_*: defect (target 3)": two independent `sms` events, not one | +| S2 `sar_*` (target 3) | defect confirmed, second peer; fixed in [#91](https://github.com/larvit/larvitsmpp/pull/91) | `jsmpp.test.ts` "sar_* (target 3)": one reassembled `sms`, segments answered `-1`/`-2` | | S3 known-but-unhandled (targets 1, 6) | pass | `jsmpp.test.ts` "query_sm, cancel_sm, replace_sm": `ESME_RINVCMDID`, link survives, jsmpp raises `NegativeResponseException` and keeps going | | S3 unknown command id (target 1) | pass | `jsmpp.test.ts` "an unknown command id gets generic_nack..." | | S3 truncated TLV stream (target 1) | pass | `jsmpp.test.ts` "a deliver_sm with a truncated TLV stream..." | @@ -100,11 +100,19 @@ Spec: SMPP 3.4 5.3.2.16-5.3.2.18 defines `sar_msg_ref_num`/`sar_total_segments`/ as an alternative to the UDH for carrying concatenation; nothing in the spec says a receiver may ignore it. -Reproducer: `jsmpp.test.ts`, "sar_\*: defect (target 3)" - two `submit_sm`s to the same +Reproducer: `jsmpp.test.ts`, "sar_\* (target 3)" - two `submit_sm`s to the same `source_addr`/`destination_addr`, `esm_class` 0x00, one `sar_msg_ref_num` (0x77) across both, `1/2` then `2/2` in `sar_total_segments`/`sar_segment_seqnum`. Severity: as already scoped in PLAN.md target 3 / phase 10 - a known, tracked limitation, not new. +**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91): `concatOf()` reads the +concatenation from the UDH, or from the `sar_*` TLVs where the PDU declares none, and each spelling +groups in a reference space of its own. The reproducer now asserts what a rerun shows - one `sms` +carrying the whole 200-char text, its two `submit_sm`s answered `-1` and `-2`, and +neither half ever reaching the application on its own. The rest of the suite is unchanged: 12/12, +frames 37, `submit_sm` 8/8, malformed 2 and expert errors 2 - the two deliberately malformed PDUs +the S3 scenarios send. + ### A 4-octet truncated TLV tail is silently accepted rather than refused (target 1) What happened: a `deliver_sm` whose mandatory fields are complete, followed by exactly one bare TLV diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts index 7a3fd83..eeb529f 100644 --- a/interop-tests/jasmin.test.ts +++ b/interop-tests/jasmin.test.ts @@ -514,15 +514,13 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); - // Recorded either way, per the task: one whole `sms` (Jasmin reassembled the SAR segments - // before forwarding) or several fragments (it relayed them, and our SAR-blind reassembler - - // keyed on UDH only - never groups them; see findings for which happened and the reproducer). const whole = await waitFor(() => sms.find(s => s.message === text), 10_000); - const fragments = sms.filter(s => text.includes(s.message) && s.message !== ''); + const fragments = sms.filter(s => s.message !== text && text.includes(s.message) && s.message !== ''); await session.close({ signal: AbortSignal.abort() }); - assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or SAR fragments to arrive'); + assert.ok(whole, 'expected the SAR segments to reassemble into one whole sms'); + assert.deepEqual(fragments.map(s => s.message), [], 'no segment reaches the application on its own'); }); test('UDH-segmented deliver_sm from the fake upstream', async () => { diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts index 1fdea20..db96513 100644 --- a/interop-tests/jsmpp.test.ts +++ b/interop-tests/jsmpp.test.ts @@ -173,7 +173,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { assert.equal(sms.answeredOnArrival, false); }); - test('sar_*: defect (target 3) - each segment reaches the application as its own sms, not one', async () => { + test('sar_* (target 3): one reassembled sms, each segment answered -', async () => { await waitForSessions(1); const text = 'sar-'.padEnd(200, 'c'); @@ -184,17 +184,13 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { assert.equal(segments.length, 2); - // Neither segment ever arrives as the whole 200-char text: each is its own ordinary sms - // carrying only its own ~130-char slice, with its own unrelated (non--) message id. - const first = await waitForSms(text.slice(0, 130), 15_000); - const second = await waitForSms(text.slice(130), 15_000); + const sms = await waitForSms(text, 15_000); - assert.equal(first.answeredOnArrival, false); - assert.equal(second.answeredOnArrival, false); - assert.notEqual(first.smsId, second.smsId); - assert.equal(allSms.some(entry => entry.sms.message === text), false); - void first; - void second; + assert.equal(sms.answeredOnArrival, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + // Neither ~130-char slice ever reached the application on its own. + assert.equal(allSms.filter(entry => text.includes(entry.sms.message)).length, 1); }); }); diff --git a/src/concat.ts b/src/concat.ts new file mode 100644 index 0000000..b56f421 --- /dev/null +++ b/src/concat.ts @@ -0,0 +1,40 @@ +import type { ConcatInfo } from './udh.ts'; +import type { PduObject } from './pdu.ts'; +import { concatInfo } from './udh.ts'; +import { hasUdh } from './defs/constants.ts'; +import { messageOctets } from './message-body.ts'; +import { paramNumber } from './defs/types.ts'; + +/** Where a segment sits in its message, and what ties it to the rest of that message. */ +export type Concat = ConcatInfo & { + /** Which of the two carried the numbering: their references are counters of their own. */ + spelling: 'sar' | 'udh'; +}; + +function sarConcat(pduObj: PduObject): Concat | undefined { + const reference = pduObj.tlvs.sar_msg_ref_num?.tagValue; + const part = pduObj.tlvs.sar_segment_seqnum?.tagValue; + const total = pduObj.tlvs.sar_total_segments?.tagValue; + + if (typeof reference !== 'number' || typeof part !== 'number' || typeof total !== 'number') { + return undefined; + } + + return { part, reference, spelling: 'sar', total }; +} + +/** + * How a PDU says it is one segment of a longer message: the UDH its body starts with, or the + * sar_* TLVs SMPP 3.4 5.3.2.31-5.3.2.33 define in its place. A UDH that names the concatenation + * wins; one carrying only a port or a language indicator leaves the TLVs to say. + */ +export function concatOf(pduObj: PduObject): Concat | undefined { + const body = messageOctets(pduObj); + const udh = body !== undefined && hasUdh(paramNumber(pduObj.params.esm_class, 0)) + ? concatInfo(body) + : undefined; + + if (udh) return { ...udh, spelling: 'udh' }; + + return sarConcat(pduObj); +} diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index dc33691..420dbf7 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -1,3 +1,4 @@ +import type { Concat } from './concat.ts'; import type { DlrMerger } from './dlr-merger.ts'; import type { ErrorName } from './defs/errors.ts'; import type { LostGroup, Refusal } from './reassembly.ts'; @@ -10,17 +11,22 @@ import type { SmsIdFormat } from './sms-id.ts'; import { HeldMessages } from './held-messages.ts'; import { Reassembler, decodeSegments } from './reassembly.ts'; import { bindCommands, defaults, standsInFor } from './session-options.ts'; -import { concatInfo } from './udh.ts'; -import { hasUdh } from './defs/constants.ts'; +import { concatOf } from './concat.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 { 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(carriedAs: string, refusal: Refusal): ErrorName { - if (refusal === 'unplaceable') return 'ESME_RINVESMCLASS'; +export function refusedSegmentStatus( + carriedAs: string, + refusal: Refusal, + spelling: Concat['spelling'], +): ErrorName { + // A sar_* segment's esm_class is 0x00 and correct: naming it would name the part the peer got right. + if (refusal === 'unplaceable') { + return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS'; + } return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN'; } @@ -197,9 +203,7 @@ 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 = messageOctets(pduObj); - const carriesUdh = hasUdh(paramNumber(pduObj.params.esm_class, 0)); - const concat = carriesUdh && message ? concatInfo(message) : undefined; + const concat = concatOf(pduObj); if (!concat) { this.emitSms([pduObj]); @@ -212,7 +216,7 @@ export class IncomingRequests { if (!collected.kept) { await this.session.sendReturn( pduObj, - refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal), + refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling), ); return; diff --git a/src/index.ts b/src/index.ts index 18ac321..e88dec0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export { export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts'; export { messageOctets } from './message-body.ts'; +export { concatOf } from './concat.ts'; export { concatInfo } from './udh.ts'; export { PduFramer } from './pdu-framer.ts'; export { uuidv7 } from './uuid.ts'; @@ -38,6 +39,7 @@ export { uuidv7 } from './uuid.ts'; export type { BindType, ClientOptions } from './client.ts'; export type { Dlr, Receipt } from './dlr.ts'; export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts'; +export type { Concat } from './concat.ts'; export type { ConcatInfo } from './udh.ts'; export type { Result, VoidResult } from './result.ts'; export type { SmppLog } from './log.ts'; diff --git a/src/reassembly.ts b/src/reassembly.ts index 7cd6cfe..bacadcb 100644 --- a/src/reassembly.ts +++ b/src/reassembly.ts @@ -1,4 +1,4 @@ -import type { ConcatInfo } from './udh.ts'; +import type { Concat } from './concat.ts'; import type { ParamValue } from './defs/types.ts'; import type { PduObject } from './pdu.ts'; import type { SmppLog } from './log.ts'; @@ -96,12 +96,14 @@ function octetsOf(pduObj: PduObject): number { return octets; } -function groupKey(pduObj: PduObject, reference: number): string { +// NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key. +function groupKey(pduObj: PduObject, concat: Concat): string { return [ paramText(pduObj.params.source_addr), paramText(pduObj.params.destination_addr), - String(reference), - ].join('_'); + concat.spelling, + String(concat.reference), + ].join('\u0000'); } /** The text of a message, joining its segments in the order they were reassembled. */ @@ -152,10 +154,10 @@ export class Reassembler { } /** The group the segment joined, and always an answer for it: an unanswered one stalls a peer. */ - collect(pduObj: PduObject, concat: ConcatInfo): Collected { + collect(pduObj: PduObject, concat: Concat): Collected { this.sweep(); - const key = groupKey(pduObj, concat.reference); + const key = groupKey(pduObj, concat); const existing = this.groups.get(key); if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' }; @@ -204,10 +206,10 @@ export class Reassembler { } } - /** Whether a segment's UDH can join a group at all: its own numbering, and the group's total. */ - private placeable(concat: ConcatInfo, existing: Group | undefined): boolean { + /** Whether a segment can join a group at all: its own numbering, and the group's total. */ + private placeable(concat: Concat, existing: Group | undefined): boolean { if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) { - this.log.warn('reassembler - dropping a segment the UDH numbers impossibly', { + this.log.warn('reassembler - dropping an impossibly numbered segment', { part: concat.part, total: concat.total, }); @@ -217,7 +219,7 @@ export class Reassembler { // Parts 1/2 and 2/3 would otherwise complete the stored two-part group as a truncated message. if (existing && existing.total !== concat.total) { - this.log.warn('reassembler - dropping a segment with an inconsistent UDH total', { + this.log.warn('reassembler - dropping a segment with an inconsistent total', { existingTotal: existing.total, part: concat.part, total: concat.total, diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 2e3ed84..313b547 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -27,6 +27,7 @@ import { objToPdu } from '../src/pdu.ts'; import { checkSessionOptions, standsInFor } from '../src/session-options.ts'; import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; +import { concatOf } from '../src/concat.ts'; import { consts } from '../src/defs/constants.ts'; import { errors } from '../src/defs/errors.ts'; import { paramNumber, paramText } from '../src/defs/types.ts'; @@ -1470,52 +1471,137 @@ 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')]); +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, - cmdLength: 0, - cmdName: 'submit_sm', - cmdStatus: 'ESME_ROK', - cmdStatusId: 0, - params: { - data_coding: 0, - destination_addr: '46709771337', - esm_class: 0x40, - short_message: body, - source_addr: '46701113311', - }, - seqNr: part, - shortMessageOctets: body, - tlvs: {}, - }; - } + return { + cmdId: 0x00000004, + cmdLength: 0, + cmdName: 'submit_sm', + cmdStatus: 'ESME_ROK', + cmdStatusId: 0, + params: { + data_coding: 0, + destination_addr: '46709771337', + esm_class: 0x40, + short_message: body, + source_addr: '46701113311', + }, + seqNr: part, + shortMessageOctets: body, + tlvs: {}, + }; +} - /** 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; +/** 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); + assert.ok(body); - return { + 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 sarTlvs(reference: number, part: number, total: number): PduObject['tlvs'] { + return { + sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: reference }, + sar_segment_seqnum: { tagId: 0x020f, tagName: 'sar_segment_seqnum', tagValue: part }, + sar_total_segments: { tagId: 0x020e, tagName: 'sar_total_segments', tagValue: total }, + }; +} + +/** The same segment numbered by the sar_* TLVs, which carry no UDH and set no esm_class bit. */ +function sarSegment(reference: number, part: number, total: number): PduObject { + const body = Buffer.from('fragment'); + const carried = segment(reference, part, total); + + return { + ...carried, + params: { ...carried.params, esm_class: 0, short_message: body }, + shortMessageOctets: body, + tlvs: sarTlvs(reference, part, total), + }; +} + +/** Reads the concatenation the way a session does, so no test can number a segment by hand. */ +function collectPdu(reassembler: Reassembler, pduObj: PduObject): Collected { + const concat = concatOf(pduObj); + + assert.ok(concat, 'the fixture must number itself as a segment'); + + return reassembler.collect(pduObj, concat); +} + +describe('where a segment says it is concatenated', () => { + // The UDH reference is 8 bits and sar_msg_ref_num 16, so one number is two unrelated counters. + test('names the spelling a segment was numbered by, alongside the reference', () => { + assert.deepEqual(concatOf(sarSegment(5, 2, 3)), { part: 2, reference: 5, spelling: 'sar', total: 3 }); + assert.deepEqual(concatOf(segment(5, 2, 3)), { part: 2, reference: 5, spelling: 'udh', total: 3 }); + }); + + test('reads a segment carrying both spellings from its UDH', () => { + const both = { ...segment(5, 1, 2), tlvs: sarTlvs(9, 2, 4) }; + + assert.deepEqual(concatOf(both), { part: 1, reference: 5, spelling: 'udh', total: 2 }); + }); + + // A UDH carrying only an application port numbers nothing, so the TLVs are all there is to read. + test('reads the sar_* TLVs where the UDH names no concatenation', () => { + const carried = sarSegment(5, 1, 2); + const body = Buffer.concat([Buffer.from([0x04, 0x04, 0x02, 0x17, 0x00]), Buffer.from('fragment')]); + const ported = { ...carried, - params: { ...carried.params, short_message: Buffer.alloc(0) }, - shortMessageOctets: Buffer.alloc(0), - tlvs: { message_payload: { tagId: 0x0424, tagName: 'message_payload', tagValue: body } }, + params: { ...carried.params, esm_class: 0x40, short_message: body }, + shortMessageOctets: body, }; - } + assert.deepEqual(concatOf(ported), { part: 1, reference: 5, spelling: 'sar', total: 2 }); + }); + + test('reads a UDH carried in message_payload', () => { + assert.deepEqual(concatOf(payloadSegment(6, 1, 2)), { part: 1, reference: 6, spelling: 'udh', total: 2 }); + }); + + test('reads no concatenation where a sar_* TLV is missing', () => { + const lone = { + ...sarSegment(5, 1, 2), + tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } }, + }; + + assert.equal(concatOf(lone), undefined); + }); + + test('reads no concatenation from a message that is not a segment', () => { + const whole = { ...sarSegment(5, 1, 2), tlvs: {} }; + + assert.equal(concatOf(whole), undefined); + }); +}); + +describe('reassembly bounds', () => { function collect( reassembler: Reassembler, reference: number, part: number, total: number, ): Collected { - return reassembler.collect(segment(reference, part, total), { part, reference, total }); + return collectPdu(reassembler, segment(reference, part, total)); + } + + function collectSar( + reassembler: Reassembler, + reference: number, + part: number, + total: number, + ): Collected { + return collectPdu(reassembler, sarSegment(reference, part, total)); } test('hands back every segment in order once the last one arrives', () => { @@ -1576,7 +1662,7 @@ describe('reassembly bounds', () => { timeout: 60_000, }); - return reassembler.collect(payloadSegment(9, 1, 2), { part: 1, reference: 9, total: 2 }); + return collectPdu(reassembler, payloadSegment(9, 1, 2)); } assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later'); @@ -1606,6 +1692,59 @@ describe('reassembly bounds', () => { ); }); + // An alphanumeric sender may carry the separator the key is built with, and two of them are two peers. + test('keeps two address pairs that differ only in where a separator sits apart', () => { + const reassembler = new Reassembler({ + log: silentLog, + max: 10, + now: () => 0, + onLost: () => undefined, + timeout: 60_000, + }); + const addressed = (source: string, destination: string): PduObject => { + const carried = segment(3, 1, 2); + + return { + ...carried, + params: { ...carried.params, destination_addr: destination, source_addr: source }, + }; + }; + + assert.equal(collectPdu(reassembler, addressed('A_B', 'C')).kept, true); + assert.equal(collectPdu(reassembler, addressed('A', 'B_C')).kept, true); + assert.equal(reassembler.size, 2, 'two senders, so two groups, and neither completes the other'); + + reassembler.clear(); + }); + + // Nothing about the bounds reads a UDH, and a group the TLVs numbered is bounded the same way. + test('bounds a sar_* group by the same count and octet caps', () => { + const counted = new Reassembler({ + log: silentLog, + max: 1, + now: () => 0, + onLost: () => undefined, + timeout: 60_000, + }); + const capped = (maxOctets: number): Reassembler => new Reassembler({ + log: silentLog, + max: 10, + maxOctets, + now: () => 0, + onLost: () => undefined, + timeout: 60_000, + }); + + assert.equal(collectSar(counted, 1, 1, 2).kept, true); + assert.equal(collectSar(counted, 2, 1, 2).kept, true); + assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would'); + counted.clear(); + + // A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry. + assert.equal(collectSar(capped(20), 3, 1, 2).kept, false); + assert.equal(collectSar(capped(30), 3, 1, 2).kept, true); + }); + // Nothing else says a message the peer has already been answered for was thrown away. test('names every group it gives up on, and why', () => { let now = 0; @@ -1626,7 +1765,8 @@ describe('reassembly bounds', () => { }); collect(reassembler, 1, 1, 2); - collect(reassembler, 2, 1, 3); + // A group numbered by the TLVs is given up on, and reported, exactly as a UDH group is. + collectSar(reassembler, 2, 1, 3); now = 61; reassembler.sweep(); @@ -1664,7 +1804,7 @@ describe('reassembly bounds', () => { Array(3).fill('unplaceable'), ); assert.equal(reassembler.size, 0); - assert.deepEqual(warnings, Array(3).fill('reassembler - dropping a segment the UDH numbers impossibly')); + assert.deepEqual(warnings, Array(3).fill('reassembler - dropping an impossibly numbered segment')); }); // Parts 1/2 then 2/3 would otherwise complete the stored two-part group, truncating the message. @@ -1757,7 +1897,7 @@ describe('reassembly bounds', () => { Buffer.concat([Buffer.from([0x05, 0x00, 0x03, 6, 2, 1]), Buffer.from('fragment')]).copy(framed); first.params.short_message = framed.subarray(0, 14); - const held = reassembler.collect(first, { part: 1, reference: 6, total: 2 }); + const held = collectPdu(reassembler, first); assert.ok(held.kept); assert.equal(held.whole, undefined); @@ -1804,10 +1944,14 @@ describe('reassembly bounds', () => { describe('the status a refused segment is answered with', () => { // SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. test('names one the command the segment arrived on defines', () => { - assert.equal(refusedSegmentStatus('submit_sm', 'full'), 'ESME_RMSGQFUL'); - assert.equal(refusedSegmentStatus('deliver_sm', 'full'), 'ESME_RX_T_APPN'); - assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable'), 'ESME_RINVESMCLASS'); - assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable'), 'ESME_RINVESMCLASS'); + assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RMSGQFUL'); + assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN'); + assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS'); + assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS'); + // esm_class is 0x00 on a sar_* segment and entirely valid: the TLV values are what cannot be honoured. + assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL'); + assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL'); + assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RMSGQFUL'); }); // Which command that is, for the one that travels both ways, is what the end it arrived at says. @@ -1817,8 +1961,8 @@ describe('the status a refused segment is answered with', () => { 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'); + assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RMSGQFUL'); + assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN'); }); }); @@ -2046,6 +2190,37 @@ describe('a peer that sends the next segment only once the last one is answered' assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVESMCLASS'); assert.deepEqual(messages, []); }); + + // Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead. + test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => { + const smpp = await startServer(t); + const messages: Sms[] = []; + + smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); + + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); + + const answered = await session.send({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', + short_message: 'part nine of two', + source_addr: '46701113311', + }, + tlvs: { + sar_msg_ref_num: { tagValue: 0x2e }, + sar_segment_seqnum: { tagValue: 9 }, + sar_total_segments: { tagValue: 2 }, + }, + }); + + assert.equal(answered.err, undefined); + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVTLVVAL'); + assert.deepEqual(messages, []); + }); }); describe('AbortSignal on a send', () => { diff --git a/test/session.test.ts b/test/session.test.ts index ca4e4f2..d3ad37d 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -716,8 +716,11 @@ describe('sending', () => { }); describe('receiving', () => { - async function inbound(t: TestContext): Promise<{ peer: Session; session: Session }> { - const smpp = await startServer(t); + async function inbound( + t: TestContext, + options: ServerOptions = {}, + ): Promise<{ peer: Session; session: Session }> { + const smpp = await startServer(t, options); const bound = once(resolve => { smpp.on('session', resolve); }); const { session } = await connect(t, smpp); @@ -1061,6 +1064,239 @@ describe('receiving', () => { assert.equal(paramText(answered.pduObj.params.message_id), ''); } }); + + /** SMPP 3.4 5.3.2.31-5.3.2.33: concatenation as optional parameters, with no UDH in the body. */ + function sarTlvs(reference: number, part: number, total: number): PduObjectInput['tlvs'] { + return { + sar_msg_ref_num: { tagValue: reference }, + sar_segment_seqnum: { tagValue: part }, + sar_total_segments: { tagValue: total }, + }; + } + + test('answers every sar_* segment on arrival and hands the application one message', async t => { + const smpp = await startServer(t); + const incoming = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); + const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); + + assert.ok(session); + + const parts = ['sar one, ', 'sar two, ', 'sar three']; + const answers: PduObject[] = []; + + // A 16-bit reference no 8-bit UDH could carry, which is the width the TLV exists for. + for (const [index, part] of parts.entries()) { + const sent = await session.send({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', + short_message: part, + source_addr: '46701113311', + }, + tlvs: sarTlvs(0x02b7, index + 1, parts.length), + }); + + assert.ok(sent.pduObj); + answers.push(sent.pduObj); + } + + const sms = await raceWithin(2000, incoming); + + assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message'); + assert.equal(sms.message, parts.join('')); + assert.equal(sms.answeredOnArrival, true); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), + [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`), + ); + }); + + test('joins sar_* segments in the order they number themselves', async t => { + const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const incoming = once(resolve => { session.on('sms', resolve); }); + const parts = ['first ', 'second ', 'third']; + + for (const index of [2, 0, 1]) { + const sent = await peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + short_message: parts[index], + source_addr: '46701113311', + }, + tlvs: sarTlvs(0x11, index + 1, parts.length), + }); + + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); + } + + const sms = await raceWithin(2000, incoming); + + assert.ok(sms, 'the segments number themselves, so the order they arrive in is not the message'); + assert.equal(sms.message, parts.join('')); + }); + + test('reassembles a sar_* segment whose body is in message_payload', async t => { + const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const incoming = once(resolve => { session.on('sms', resolve); }); + const parts = ['in the mandatory field, ', 'and in the TLV']; + + for (const [index, part] of parts.entries()) { + // The combination that needs no UDH at all: the numbering and the body are both optional + // parameters, so the second segment's short_message is empty. + const carried = index === 0 + ? { params: { short_message: part }, tlvs: {} } + : { + params: { short_message: Buffer.alloc(0) }, + tlvs: { message_payload: { tagValue: Buffer.from(part, 'ascii') } }, + }; + const sent = await peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + source_addr: '46701113311', + ...carried.params, + }, + tlvs: { ...sarTlvs(0x12, index + 1, parts.length), ...carried.tlvs }, + }); + + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); + } + + const sms = await raceWithin(2000, incoming); + + assert.ok(sms, 'a segment carries its body where any other message may carry one'); + assert.equal(sms.message, parts.join('')); + assert.equal(sms.answeredOnArrival, true); + }); + + // The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages. + test('keeps a UDH group and a sar_* group sharing a reference apart', async t => { + const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const messages: Sms[] = []; + + session.on('sms', sms => { messages.push(sms); }); + + const udhText = 'the message numbered by its user data header. '.repeat(5); + const udhSegments = splitMessage(udhText, { reference: 5 }); + const sarParts = ['the message numbered by ', 'its optional parameters']; + + assert.equal(udhSegments.length, 2); + + const sends: PduObjectInput[] = []; + + for (const [index, segment] of udhSegments.entries()) { + sends.push({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + esm_class: consts.ESM_CLASS.UDH_INDICATOR, + short_message: segment, + source_addr: '46701113311', + }, + }); + sends.push({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + short_message: sarParts[index], + source_addr: '46701113311', + }, + tlvs: sarTlvs(5, index + 1, sarParts.length), + }); + } + + for (const input of sends) { + const sent = await peer.send(input); + + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); + } + + assert.ok(await waitFor(() => messages.length === 2)); + assert.deepEqual(messages.map(sms => sms.message).sort(), [sarParts.join(''), udhText].sort()); + assert.notEqual(messages[0]?.smsId, messages[1]?.smsId); + }); + + // Nothing compares the two references: each spelling counts in a space of its own. + test('groups a segment carrying both spellings by its UDH', async t => { + const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const incoming = once(resolve => { session.on('sms', resolve); }); + const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4); + const segments = splitMessage(message, { reference: 7 }); + + assert.equal(segments.length, 2); + + for (const [index, segment] of segments.entries()) { + const sent = await peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + esm_class: consts.ESM_CLASS.UDH_INDICATOR, + short_message: segment, + source_addr: '46701113311', + }, + // Numbering the same segments as a three-part message no third segment ever completes. + tlvs: sarTlvs(0x0207, index + 1, 3), + }); + + assert.ok(sent.pduObj); + } + + const sms = await raceWithin(2000, incoming); + + assert.ok(sms, 'the UDH says the message is whole at two segments, and the UDH is what is read'); + assert.equal(sms.message, message); + }); + + test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => { + const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const reports: Dlr[] = []; + let messages = 0; + + session.on('dlr', dlr => { reports.push(dlr); }); + session.on('sms', () => { messages++; }); + + const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63'; + const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b'; + const body = (smsId: string): string => + `id:${smsId} sub:001 dlvrd:001 submit date:2509061200 done date:2509061201 stat:DELIVRD err:000 text:`; + const receipts: PduObjectInput[] = [ + { + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, + short_message: body(marked), + source_addr: '46701113311', + }, + tlvs: sarTlvs(0x21, 1, 2), + }, + // esm_class marks nothing, so the receipted_message_id TLV is what says it is a report. + { + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + short_message: body(unmarked), + source_addr: '46701113311', + }, + tlvs: { ...sarTlvs(0x21, 2, 2), receipted_message_id: { tagValue: unmarked } }, + }, + ]; + + for (const receipt of receipts) { + const sent = await peer.send(receipt); + + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); + } + + assert.ok(await waitFor(() => reports.length === 2)); + assert.deepEqual(reports.map(report => report.smsId), [marked, unmarked]); + assert.equal(messages, 0, 'a report is never a segment, however the peer numbered it'); + }); }); describe('delivery reports', () => {