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
This commit is contained in:
2026-09-06 21:03:14 +02:00
committed by GitHub
parent 061c1871bd
commit 18de565aad
12 changed files with 595 additions and 89 deletions
+32 -3
View File
@@ -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
+14 -3
View File
@@ -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,
+5
View File
@@ -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
+10 -2
View File
@@ -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 `<base>-1`/`<base>-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 `<base>-1`/`<base>-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 `<base>-1` and `<base>-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
+3 -5
View File
@@ -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 () => {
+7 -11
View File
@@ -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 <base>-<n>', 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-<base>-<n>) 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);
});
});
+40
View File
@@ -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);
}
+14 -10
View File
@@ -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<void> {
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;
+2
View File
@@ -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';
+12 -10
View File
@@ -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,
+192 -17
View File
@@ -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,8 +1471,7 @@ describe('sendDlr()', () => {
});
});
describe('reassembly bounds', () => {
function segment(reference: number, part: number, total: number): PduObject {
function segment(reference: number, part: number, total: number): PduObject {
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
const body = Buffer.concat([udh, Buffer.from('fragment')]);
@@ -1492,10 +1492,10 @@ describe('reassembly bounds', () => {
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 {
/** 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;
@@ -1507,15 +1507,101 @@ describe('reassembly bounds', () => {
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, 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', () => {
+238 -2
View File
@@ -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<Session>(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<Sms>(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<Sms>(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<Sms>(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<Sms>(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', () => {