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
+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);
});
});