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
+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,