Declare the alphabet a GSM message is actually written in (#100)

* Regression tests for the alphabet a GSM message declares

* Declare the alphabet a GSM message is actually written in

* Reflow the composing-by-hand paragraph

* Drop the ASCII alias SMPP's flat table shares with the option's own

* Bound the receipt test's wait and tighten the notes around it
This commit is contained in:
2026-09-09 21:30:55 +02:00
committed by GitHub
parent 989eb01763
commit ca1a7473ed
15 changed files with 275 additions and 33 deletions
+33
View File
@@ -152,6 +152,7 @@ naming the behaviour.
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed | | Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` | | DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) | | Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
| GSM 03.38 declared as IA5 | `sendSms` resolves its encoding through `consts.ENCODING`, so a GSM body goes out under `data_coding` 0x01 — SMPP 3.4 5.2.19's IA5 (CCITT T.50), where `$` and `@` are STX and NUL |
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 | | Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process | | Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles | | `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
@@ -670,6 +671,38 @@ Grouped by what each one constrains.
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
own definition. own definition.
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT
T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's
STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the
same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written
under, as `unencodable()` is to whether one can carry a message: the mirror of
`encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected
one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is
exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a
`data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the
*SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee;
it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile
and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and
so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01
to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no
researched peer means IA5 by it. The two tables agree over most of the printable range and part at
0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed,
carriage return and escape are common to both — which is where a peer that did mean IA5 is
misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two
names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different
values and so the only one a reader could carry from the option's vocabulary into SMPP's flat
table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name.
Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the
section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding`
option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as
Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing
researched has found. Accepted: a message already in flight is unmoved — both codings resolve to
the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
change reads exactly as it did.
### The session's life ### The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call, - **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
+12 -3
View File
@@ -599,9 +599,11 @@ how binary payloads, hand-built user data headers and deliberately malformed bod
Composing a message by hand takes the send-side pair: `unencodable(message, encoding)` gives Composing a message by hand takes the send-side pair: `unencodable(message, encoding)` gives
`{ char, index }` for the first character an alphabet cannot carry and `undefined` where it carries `{ char, index }` for the first character an alphabet cannot carry and `undefined` where it carries
them all, which is the check `sendSms()` makes before it encodes anything; `smppTime.encode(value)` them all, which is the check `sendSms()` makes before it encodes anything;
returns `{ err, text }` for a `validity_period` or `schedule_delivery_time`, as `smppTime.decode()` `dataCodingByEncoding[encoding]` is the `data_coding` this library writes each alphabet under, which
returns `{ err, date }` for one that arrived. is what to put beside octets `encodeMessage()` handed back; `smppTime.encode(value)` returns
`{ err, text }` for a `validity_period` or `schedule_delivery_time`, as `smppTime.decode()` returns
`{ err, date }` for one that arrived.
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
`types`, and the matching `*ById` maps) and grouped as `defs`. `types`, and the matching `*ById` maps) and grouped as `defs`.
@@ -640,6 +642,9 @@ promises and the rough edges taken off.
- **The `error` event is `sessionError`** (and `serverError` on the server handle). - **The `error` event is `sessionError`** (and `serverError` on the server handle).
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a
`larvitutils` one, and is silent by default. See [Logging](#logging). `larvitutils` one, and is silent by default. See [Logging](#logging).
- **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP
3.4 5.2.19 gives 0x01. Reach for `dataCodingByEncoding` where you meant the alphabet `sendSms()`
writes — that is 0x00, not this.
### Behaviour that changed on the wire ### Behaviour that changed on the wire
@@ -659,6 +664,10 @@ have worked around any of these, remove the workaround:
GSM alphabet on a Latin-1 message that has no `data_coding` at all — that pair is refused now. GSM alphabet on a Latin-1 message that has no `data_coding` at all — that pair is refused now.
Inbound, only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the Inbound, only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the
whole 0xF0 coding group arrived as ordinary messages. whole 0xF0 coding group arrived as ordinary messages.
- A GSM 03.38 message declared `data_coding` 0x01, which SMPP 3.4 5.2.19 defines as IA5 rather than
the alphabet those octets are in, so `$` and `@` reached a peer honouring the field as STX and NUL.
It goes out as 0x00, the SMSC default alphabet, and so does a receipt `sendDlr()` writes. Latin-1
and UCS2 are unmoved at 0x03 and 0x08, and an inbound 0x01 is still read as GSM 03.38.
- The multipart reference counter was shared by every session in the process. - The multipart reference counter was shared by every session in the process.
- `tls: true` never performed a handshake, so the connection was not actually encrypted. - `tls: true` never performed a handshake, so the connection was not actually encrypted.
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5. - Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
+1 -1
View File
@@ -276,7 +276,7 @@ describe('smppsim-transition - C4 intermediate then final', () => {
const sent = await session.send({ const sent = await session.send({
cmdName: 'submit_sm', cmdName: 'submit_sm',
params: { params: {
data_coding: consts.ENCODING.ASCII, data_coding: 0,
destination_addr: TO, destination_addr: TO,
registered_delivery: 0x11, registered_delivery: 0x11,
short_message: Buffer.from('transition test', 'latin1'), short_message: Buffer.from('transition test', 'latin1'),
-1
View File
@@ -22,7 +22,6 @@ export const consts = {
YEARS: 0x0E, YEARS: 0x0E,
}, },
ENCODING: { ENCODING: {
ASCII: 0x01,
BINARY: 0x04, BINARY: 0x04,
CYRILLIC: 0x06, CYRILLIC: 0x06,
EXTENDED_KANJI_JIS: 0x0D, EXTENDED_KANJI_JIS: 0x0D,
+10
View File
@@ -188,3 +188,13 @@ export function encodingByDataCoding(dataCoding: number): EncodingName {
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1. // 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII'; return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
} }
/**
* The `data_coding` an alphabet is written under, the mirror of `encodingByDataCoding()`. GSM 03.38
* takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5.
*/
export const dataCodingByEncoding: Readonly<Record<EncodingName, number>> = {
ASCII: 0x00,
LATIN1: 0x03,
UCS2: 0x08,
};
+1 -1
View File
@@ -4,7 +4,7 @@ export { Session } from './session.ts';
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts'; export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
export { consts, constsById } from './defs/constants.ts'; export { consts, constsById } from './defs/constants.ts';
export { detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts'; export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts'; export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
export { tlvs, tlvsById } from './defs/tlvs.ts'; export { tlvs, tlvsById } from './defs/tlvs.ts';
export { types } from './defs/types.ts'; export { types } from './defs/types.ts';
+3 -3
View File
@@ -1,7 +1,7 @@
import type { Result } from './result.ts'; import type { Result } from './result.ts';
import type { EncodingName } from './defs/encodings.ts'; import type { EncodingName } from './defs/encodings.ts';
import { detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts'; import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
import { consts, hasUdh } from './defs/constants.ts'; import { hasUdh } from './defs/constants.ts';
import { udhLength } from './udh.ts'; import { udhLength } from './udh.ts';
/** A single SMS carries 1120 bits, whatever the alphabet. */ /** A single SMS carries 1120 bits, whatever the alphabet. */
@@ -35,7 +35,7 @@ export function encodeBody(
if (dataCoding === undefined) { if (dataCoding === undefined) {
const detected = encodeMessage(text); const detected = encodeMessage(text);
return { buffer: detected.buffer, dataCoding: consts.ENCODING[detected.encoding] }; return { buffer: detected.buffer, dataCoding: dataCodingByEncoding[detected.encoding] };
} }
const encoding = encodingByDataCoding(dataCoding); const encoding = encodingByDataCoding(dataCoding);
+2 -2
View File
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
import type { SmsIdNotation } from './sms-id.ts'; import type { SmsIdNotation } from './sms-id.ts';
import { UnansweredError } from './unanswered-error.ts'; import { UnansweredError } from './unanswered-error.ts';
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
import { detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts'; import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
import { namedValue } from './error-from.ts'; import { namedValue } from './error-from.ts';
import { normaliseSmsId } from './sms-id.ts'; import { normaliseSmsId } from './sms-id.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
@@ -89,7 +89,7 @@ function addressTon(address: string): number {
} }
function dataCodingFor(encoding: EncodingName, flash: boolean): number { function dataCodingFor(encoding: EncodingName, flash: boolean): number {
if (!flash) return consts.ENCODING[encoding]; if (!flash) return dataCodingByEncoding[encoding];
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2. // Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
return encoding === 'UCS2' ? 0x18 : 0x10; return encoding === 'UCS2' ? 0x18 : 0x10;
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { client } from '../src/client.ts';
import { closeAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts';
import { decodeMessage } from '../src/message.ts';
import { dlrFromPdu } from '../src/dlr.ts';
import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
import { paramNumber } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import type { PduObject } from '../src/pdu.ts';
const from = '46701113311';
const to = '46709771337';
/** GSM 03.38 puts $ at 0x02 and @ at 0x00, where IA5 has STX and NUL. */
const bothTables = 'Cost 5$ @home';
/** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */
const byTheSpecsTable: Record<number, (octets: Buffer) => string> = {
// The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38.
0x00: octets => encodings.ASCII.decode(octets),
// IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80.
[consts.ENCODING.IA5]: octets => octets.toString('latin1'),
};
/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('waited 5000 ms for an event that never fired'));
}, 5000);
register(value => {
clearTimeout(timer);
resolve(value);
});
});
}
function submitted(octets: Buffer[]): PduObject[] {
return octets.map(pdu => {
const { pduObj } = pduToObj(pdu);
assert.ok(pduObj);
return pduObj;
});
}
function declaredBy(pduObj: PduObject): number {
return paramNumber(pduObj.params.data_coding, 0);
}
/** A deliver_sm carrying `body` under `dataCoding`, as a peer answering our own send would write it. */
function delivered(body: Buffer | string, dataCoding: number, esmClass: number): PduObject {
const { buffer } = objToPdu({
cmdName: 'deliver_sm',
params: {
data_coding: dataCoding,
destination_addr: from,
esm_class: esmClass,
short_message: body,
source_addr: to,
},
seqNr: 1,
});
assert.ok(buffer);
const { pduObj } = pduToObj(buffer);
assert.ok(pduObj);
return pduObj;
}
describe('the alphabet a message declares is the one its octets are written in', () => {
test('sends GSM 03.38 under data_coding 0x00, the SMSC default alphabet', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de97-7caa-9d26-e6d50f5c4888'] });
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const sent = await session.sendSms({ from, message: bothTables, to });
assert.equal(sent.err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x00]);
});
test('keeps $ and @ for a peer that honours the declaration, where IA5 read STX and NUL', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de98-7d24-9542-e652e0d3761c'] });
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
assert.equal((await session.sendSms({ from, message: bothTables, to })).err, undefined);
const [pduObj] = submitted(smsc.octets);
assert.ok(pduObj);
const octets = pduObj.shortMessageOctets;
assert.ok(octets);
assert.equal(octets.toString('hex'), '436f73742035022000686f6d65');
const read = byTheSpecsTable[declaredBy(pduObj)];
assert.ok(read, 'the coding declared must be one SMPP 3.4 5.2.19 names an alphabet for');
assert.equal(read(octets), bothTables);
});
test('leaves Latin-1 at 0x03 and UCS2 at 0x08, the codings those alphabets always had', async t => {
const smsc = await dummySmsc(t, {
messageIds: ['01a08779-de99-7fa5-bcac-18feef55aeee', '01a08779-de99-72ec-8dbb-9365a00158c3'],
});
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
assert.equal((await session.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined);
assert.equal((await session.sendSms({ from, message: 'あいう', to })).err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x03, 0x08]);
});
// sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too.
test('declares 0x00 on a receipt it writes itself', async t => {
const { err, server: smpp } = await server({ port: 0 });
assert.equal(err, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
smpp.on('session', peer => peer.on('sms', async sms => {
await sms.sendResp();
await sms.sendDlr('DELIVERED');
}));
const connected = await client({ port: smpp.port, reconnect: false });
assert.equal(connected.err, undefined);
assert.ok(connected.session);
closeAfter(t, connected.session);
const session = connected.session;
const reported = once<PduObject>(resolve => {
session.on('dlr', (_report, pduObj) => { resolve(pduObj); });
});
assert.equal((await session.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined);
assert.equal(declaredBy(await reported), 0x00);
});
// The low-level surface settles data_coding off the same detection, so it carried the same defect.
test('settles a detected string body at 0x00 where the caller named no data_coding', () => {
const built = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: bothTables, source_addr: from },
});
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(declaredBy(pduObj), 0x00);
assert.equal(pduObj.shortMessageOctets?.toString('hex'), '436f73742035022000686f6d65');
});
});
describe('what a peer declares is read as generously as it was before', () => {
test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => {
assert.equal(encodingByDataCoding(0x01), 'ASCII');
const octets = Buffer.from('436f73742035022000686f6d65', 'hex');
for (const dataCoding of [0x00, 0x01]) {
assert.equal(decodeMessage(octets, dataCoding).message, bothTables, String(dataCoding));
}
});
test('leaves a receipt and an inbound message reading the same under either coding', () => {
const receiptId = '01a08779-de97-7caa-9d26-e6d50f5c4888';
const body = `id:${receiptId} sub:001 dlvrd:001 submit date:2509091430 done date:2509091431 stat:DELIVRD err:000 text:${bothTables}`;
for (const dataCoding of [0x00, 0x01]) {
const receipt = dlrFromPdu(delivered(body, dataCoding, consts.ESM_CLASS.MC_DELIVERY_RECEIPT));
assert.ok(receipt, String(dataCoding));
assert.equal(receipt.smsId, receiptId, String(dataCoding));
assert.equal(receipt.statusMsg, 'DELIVERED', String(dataCoding));
const inbound = delivered(Buffer.from('436f73742035022000686f6d65', 'hex'), dataCoding, 0);
assert.equal(dlrFromPdu(inbound), undefined, String(dataCoding));
assert.equal(inbound.params.short_message, bothTables, String(dataCoding));
}
});
});
+9 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import { detect, encodingByDataCoding, encodings, unencodable } from '../src/defs/encodings.ts'; import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts';
describe('ASCII (GSM 03.38)', () => { describe('ASCII (GSM 03.38)', () => {
const samples: [string, number[]][] = [ const samples: [string, number[]][] = [
@@ -188,6 +188,14 @@ describe('encodingByDataCoding()', () => {
} }
}); });
test('reads every coding dataCodingByEncoding writes back as the alphabet that wrote it', () => {
for (const name of Object.keys(dataCodingByEncoding)) {
if (!isEncodingName(name)) return assert.fail(`${name} names no alphabet`);
assert.equal(encodingByDataCoding(dataCodingByEncoding[name]), name);
}
});
test('falls back to ASCII for alphabets it has no codec for', () => { test('falls back to ASCII for alphabets it has no codec for', () => {
assert.equal(encodingByDataCoding(0x05), 'ASCII'); assert.equal(encodingByDataCoding(0x05), 'ASCII');
assert.equal(encodingByDataCoding(0x0E), 'ASCII'); assert.equal(encodingByDataCoding(0x0E), 'ASCII');
+1 -1
View File
@@ -171,7 +171,7 @@ describe('sendSms() flash', () => {
} }
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message. // 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
assert.deepEqual(dataCodingsOf(smsc.octets), [0x01, 0x10, 0x08, 0x18, 0x03]); assert.deepEqual(dataCodingsOf(smsc.octets), [0x00, 0x10, 0x08, 0x18, 0x03]);
}); });
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => { test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
+4 -4
View File
@@ -19,13 +19,13 @@ const to = '46709771337';
const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7); const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7);
/** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */ /** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000001000b48656c6c6f20776f726c64'; const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000000000b48656c6c6f20776f726c64';
/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */ /** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */
const threeSegmentOctets = [ const threeSegmentOctets = [
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000001009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564', '000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000000009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000001009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66', '000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000000009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000100710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20', '000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000000710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
]; ];
type BoundPeer = { type BoundPeer = {
+3 -2
View File
@@ -130,7 +130,8 @@ describe('parsing real PDUs', () => {
}); });
describe('encoding submit_sm', () => { describe('encoding submit_sm', () => {
test('produces the same bytes as 0.4.0 for a GSM message', () => { // 0.4.0 declared IA5 for the GSM octets it wrote; that one field is the whole difference.
test('produces the 0.4.0 bytes for a GSM message, under the alphabet those octets are in', () => {
const pdu = encode({ const pdu = encode({
cmdName: 'submit_sm', cmdName: 'submit_sm',
cmdStatus: 'ESME_ROK', cmdStatus: 'ESME_ROK',
@@ -144,7 +145,7 @@ describe('encoding submit_sm', () => {
assert.equal( assert.equal(
pdu.toString('hex'), pdu.toString('hex'),
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000001000b48656c6c6f20776f726c64', '0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000000000b48656c6c6f20776f726c64',
); );
}); });
+1 -1
View File
@@ -109,7 +109,7 @@ describe('an alphabet the caller named that cannot carry the message', () => {
assert.equal((await session.sendSms({ from, message, to })).err, undefined, message); assert.equal((await session.sendSms({ from, message, to })).err, undefined, message);
} }
assert.deepEqual(sentAs(smsc.octets), [[0x01, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]); assert.deepEqual(sentAs(smsc.octets), [[0x00, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
}); });
}); });
-13
View File
@@ -120,19 +120,6 @@ session message is a change to every call site.
another message's in a correlation table. The cost is that every consumer narrows, including another message's in a correlation table. The cost is that every consumer narrows, including
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08. the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
**This one is 1.0.0-or-never** — after release it needs a major version. **This one is 1.0.0-or-never** — after release it needs a major version.
- [ ] **A default GSM send declares IA5, not GSM 03.38.** `dataCodingFor()` resolves `encoding`
through `consts.ENCODING`, so `'ASCII'` goes out as `data_coding` 0x01 — SMPP 3.4 5.2.19's
*IA5 (CCITT T.50)/ASCII* — while the codec writes the GSM 03.38 table, where `@` is 0x00 and
`$` is 0x02 against IA5's NUL and STX. `encodingByDataCoding()` reads 0x00 back as GSM 03.38,
so this library's writer and reader disagree, and every peer in `interop-tests/` submits GSM
text at `data_coding` 0. Nothing asserts what our own default send declares, and the echo
tests use characters where the two tables agree, which is why the suite is green.
`test/message-class.test.ts` pins the current value. Goal 1 owns it. The fix is a three-entry
map in `send-sms.ts` (`ASCII` → 0x00, `LATIN1` → 0x03, `UCS2` → 0x08) rather than any move of
a published constant — `consts.ENCODING.ASCII` = 0x01 is a correct name for SMPP's flat-table
IA5 entry, and the defect is using that group as the option's vocabulary. **Which value an
operator should see is the maintainer's call**, and it wants an `interop-tests/` run. Raised
by the architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09.
- [ ] Tag `v1.0.0` to publish. - [ ] Tag `v1.0.0` to publish.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not - [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do. something CI should do.