From e80b07167c09582b48d224ce64303534fbadb431 Mon Sep 17 00:00:00 2001 From: Lillem4n Date: Wed, 9 Sep 2026 16:40:32 +0200 Subject: [PATCH] Read the GSM 03.38 message class where the spec puts it (#95) * Regression tests for the GSM 03.38 message class behind sms.flash * Read the GSM 03.38 message class where the spec puts it, and refuse a flash message no alphabet can carry * Keep the code span in the message-class decision on one line * Stop offering the flash message class as an alphabet, and check the encoding option by name * Settle every refusable send option in one check, and record what the review left open * Say what the flash refusal and the encoding option actually do * Keep the README off the alphabet whose long messages do not fit --- AGENTS.md | 45 +++++- README.md | 24 ++- interop-tests/findings/02-smppsim.md | 4 + src/defs/encodings.ts | 39 +++-- src/index.ts | 2 +- src/message.ts | 2 +- src/send-sms.ts | 65 ++++++-- src/sms.ts | 7 +- test/encodings.test.ts | 13 ++ test/message-class.test.ts | 225 +++++++++++++++++++++++++++ todo.md | 27 +++- 11 files changed, 422 insertions(+), 31 deletions(-) create mode 100644 test/message-class.test.ts diff --git a/AGENTS.md b/AGENTS.md index ccac03f..7a92543 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,10 @@ exactly 140. stays there because that is a different peer rather than a second copy of this one. The waiting helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same - footing — it encodes no wire fact `objToPdu()` does not already own. + footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a + collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts` + and `message-class.test.ts` is one `SendSmsDeps.send` that answers nothing, and a field added to + that type fails to compile in both copies at once. - `message_id` values the library generates are UUID v7. - A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one. @@ -421,6 +424,46 @@ Grouped by what each one constrains. which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing but `data_coding` can say how a message was written. +- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message + with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target + of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the + ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only + one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM + 03.38, and the one SMPPSim demonstrated + ([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17). + `messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as + `concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0, + carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and + `encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group + masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class + other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the + `sms` event, which pays goal 6 for three classes nothing here acts on, where the boolean the + application already had covers the one it does. Compressed text is out of scope and stays out — + nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever + its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as + class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38 + coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an + extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the + alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the + class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins + (0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is + spelling 03.38 at all. Send-side: `flash` + is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while + `encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this + library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is + SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets, + which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to + the handset that would display it; rejected: promoting it to UCS2, which overrides the one option + the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which + named `data_coding` 0x10 among the alphabets and so reached the class through the option that + chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character + to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()` + and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a + caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING` + keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group + as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no + peer means as immediate display. + - **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and `SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final, `UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken diff --git a/README.md b/README.md index 51be943..407ea03 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,16 @@ defines the delivery report away, so pair it with `dlr: true` and the send is re leaving you waiting for a report that cannot come — as is any value naming no mode, both before a segment goes out. +`encoding` names the alphabet, and is `ASCII` (GSM 03.38's own 7-bit table), `LATIN1` or `UCS2`; +anything else is refused by name rather than guessed at. Leave it out and a message that fits GSM +7-bit goes as `ASCII` and everything else as `UCS2`; `LATIN1` is only ever used when you name it. + +`flash` asks for GSM 03.38 message class 0, the class a handset shows on arrival instead of storing. +It travels in `data_coding` beside the alphabet, so a flash UCS2 message stays UCS2. Pairing it with +`encoding: 'LATIN1'` is the one combination with nowhere to go — a message class carries GSM 7-bit, +8-bit data or UCS2, and 8-bit data is not text a handset will display — so the send is refused +before anything goes out. + Messages too long for one SMS are split automatically and sent as a concatenated message. You get one id per segment: @@ -157,6 +167,10 @@ session.on('sms', async sms => { }); ``` +`sms.flash` is true where the message's `data_coding` carries GSM 03.38 message class 0, in every +coding group that carries one — so `0x10`, `0x18`, `0x50` and `0xF0` alike. The other three classes +name where the handset stores the message rather than that it displays it, so they are not flash. + Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has to tell the two apart. `esm_class` is what tells them apart; where it names no message type a `receipted_message_id` TLV does, and failing both the message body is read for the standard @@ -545,7 +559,10 @@ answer to which of the two the peer used, and gives back the octets undecoded: `decodeMessage(octets, pduObj.params.data_coding, pduObj.params.esm_class)` turns them into text and hands back the UDH where the PDU carries one. `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. +carried them, or `undefined` where the PDU is a whole message. `messageClassOf(dataCoding)` is the +same for the GSM 03.38 message class: `0` for the flash class `sms.flash` already reports, `1`, `2` +and `3` for the ME-, SIM- and TE-specific ones, and `undefined` where that `data_coding`'s coding +group carries no class at all. The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, and the matching `*ById` maps) and grouped as `defs`. @@ -599,7 +616,10 @@ have worked around any of these, remove the workaround: - Every receipt went out as `esm_class` 0x04, which SMPP 3.4 defines as the report of a message's final state. A receipt for a transient state — `sendDlr('ENROUTE')` — is now marked 0x20, the intermediate delivery notification. -- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters. +- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters, and put the + 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 + whole 0xF0 coding group arrived as ordinary messages. - 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. - Alphanumeric senders were sent with TON 1 (international) instead of TON 5. diff --git a/interop-tests/findings/02-smppsim.md b/interop-tests/findings/02-smppsim.md index 044fdc9..9d97daf 100644 --- a/interop-tests/findings/02-smppsim.md +++ b/interop-tests/findings/02-smppsim.md @@ -60,6 +60,10 @@ tests passing, `malformed: 0`, `expert errors: 0` in both. | C17 (encodings over loopback) | pass | `smppsim - C17 …`, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (not flash), raw UDH+8-bit-binary | | C18 (`smppsim-outbind`) | pass (record, not judge) | `smppsim-outbind - C18 …`; see below for the wire facts | +C17's `raw 0xF0 (not flash)` recorded a `@larvit/smpp` defect, fixed in +[#95](https://github.com/larvit/larvitsmpp/pull/95): 0xF0 is GSM 03.38 message class 0, so it reads +as flash now. + ## Defects in @larvit/smpp ### A delivery receipt's `data_coding` is trusted to decode its body, even though the spec makes the receipt a fixed text format diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts index a277e3c..75eccf0 100644 --- a/src/defs/encodings.ts +++ b/src/defs/encodings.ts @@ -1,4 +1,4 @@ -export type EncodingName = 'ASCII' | 'FLASH' | 'LATIN1' | 'UCS2'; +export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2'; export type Encoding = { decode: (buffer: Uint8Array) => string; @@ -104,11 +104,16 @@ const ucs2: Encoding = { export const encodings: Record = { ASCII: ascii, - FLASH: ascii, LATIN1: latin1, UCS2: ucs2, }; +export const encodingNames: readonly string[] = Object.keys(encodings); + +export function isEncodingName(value: unknown): value is EncodingName { + return typeof value === 'string' && Object.hasOwn(encodings, value); +} + export function detect(value: string): EncodingName { if (encodings.ASCII.match(value)) return 'ASCII'; if (encodings.LATIN1.match(value)) return 'LATIN1'; @@ -116,21 +121,33 @@ export function detect(value: string): EncodingName { return 'UCS2'; } -/** The 0x1X and 0xFX ranges carry a GSM message class and put the alphabet in bits 3-2 or bit 2. */ -function messageClassEncoding(dataCoding: number): EncodingName | undefined { - if ((dataCoding & 0xF0) === 0x10) { - const alphabet = (dataCoding >> 2) & 0x03; - - if (alphabet === 0x01) return 'LATIN1'; - - return alphabet === 0x02 ? 'UCS2' : 'ASCII'; +/** + * The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined + * where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is. + */ +export function messageClassOf(dataCoding: number): number | undefined { + if ((dataCoding & 0x80) === 0) { + return (dataCoding & 0x10) === 0x10 ? dataCoding & 0x03 : undefined; } + return (dataCoding & 0xF0) === 0xF0 ? dataCoding & 0x03 : undefined; +} + +// A class is the only evidence a peer below 0x80 is spelling 03.38 rather than SMPP's flat table, +// which contradicts it and wins: 0x03 is Latin-1 here, GSM 7-bit there. +/** A class group puts the alphabet in bits 3-2, or in bit 2 alone above 0xF0. */ +function messageClassEncoding(dataCoding: number): EncodingName | undefined { + if (messageClassOf(dataCoding) === undefined) return undefined; + if ((dataCoding & 0xF0) === 0xF0) { return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII'; } - return undefined; + const alphabet = (dataCoding >> 2) & 0x03; + + if (alphabet === 0x01) return 'LATIN1'; + + return alphabet === 0x02 ? 'UCS2' : 'ASCII'; } /** diff --git a/src/index.ts b/src/index.ts index ef41ec4..0d3701a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ export { Session } from './session.ts'; export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts'; export { consts, constsById } from './defs/constants.ts'; -export { detect, encodingByDataCoding, encodings } from './defs/encodings.ts'; +export { detect, encodingByDataCoding, encodings, messageClassOf } from './defs/encodings.ts'; export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts'; export { tlvs, tlvsById } from './defs/tlvs.ts'; export { types } from './defs/types.ts'; diff --git a/src/message.ts b/src/message.ts index 82b0834..b825bfa 100644 --- a/src/message.ts +++ b/src/message.ts @@ -51,7 +51,7 @@ export function bitCount(message: string, encoding?: EncodingName): number { const encoded = encodings[resolved].encode(message); // GSM characters are packed seven bits to a septet; everything else stays octet-aligned. - return resolved === 'ASCII' || resolved === 'FLASH' ? encoded.length * 7 : encoded.length * 8; + return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8; } /** diff --git a/src/send-sms.ts b/src/send-sms.ts index 8729e2d..9e0d1e7 100644 --- a/src/send-sms.ts +++ b/src/send-sms.ts @@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts'; import type { SmsIdNotation } from './sms-id.ts'; import { UnansweredError } from './unanswered-error.ts'; import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; -import { detect } from './defs/encodings.ts'; +import { detect, encodingNames, isEncodingName } from './defs/encodings.ts'; import { namedValue } from './error-from.ts'; import { normaliseSmsId } from './sms-id.ts'; import { paramText } from './defs/types.ts'; @@ -32,8 +32,11 @@ export type SendSmsOptions = { validityPeriod?: Date | number | string; }; -/** The options as they arrive: a caller without types can put anything in the checked field. */ -export type SendSmsInput = Omit & { messagingMode?: unknown }; +/** The options as they arrive: a caller without types can put anything in the checked fields. */ +export type SendSmsInput = Omit & { + encoding?: unknown; + messagingMode?: unknown; +}; /** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */ export type SendSmsResult = { @@ -57,6 +60,12 @@ export type SendSmsDeps = { send: (input: PduObjectInput) => Promise>; }; +/** What the checks below settle, before a segment exists to carry it. */ +type CheckedOptions = { + encoding: EncodingName; + messagingMode: SubmitMessagingMode; +}; + type SegmentOptions = { encoding: EncodingName; messagingMode?: SubmitMessagingMode; @@ -140,6 +149,45 @@ function checkMessagingMode( return { err: refusedMode(mode) }; } +function refusedEncoding(encoding: unknown): Error { + if (encoding === 'FLASH') { + return new Error('encoding FLASH is a message class rather than an alphabet; ask for it as flash: true beside the alphabet you want'); + } + + return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`); +} + +function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> { + if (encoding === undefined) return { encoding: detect(message) }; + if (isEncodingName(encoding)) return { encoding }; + + return { err: refusedEncoding(encoding) }; +} + +/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */ +function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined { + if (!flash || encoding !== 'LATIN1') return undefined; + + return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash'); +} + +/** Every option a send can be refused for, so nothing is built for a message that will not go. */ +function checkOptions(sms: SendSmsInput): Result { + const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true); + + if (mode.err) return { err: mode.err }; + + const chosen = checkEncoding(sms.encoding, sms.message); + + if (chosen.err) return { err: chosen.err }; + + const unspellable = checkFlash(chosen.encoding, sms.flash === true); + + if (unspellable) return { err: unspellable }; + + return { encoding: chosen.encoding, messagingMode: mode.messagingMode }; +} + /** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */ function checkSegments(allowed: number, segments: number): Error | undefined { if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) { @@ -186,14 +234,13 @@ function collectSent( /** Puts a message on the wire as one submit_sm per segment. */ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise { - const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true); + const options = checkOptions(sms); - if (mode.err) return unsent(mode.err); + if (options.err) return unsent(options.err); - const allowed = sms.maxSegments ?? maxSegments; - const encoding = sms.encoding ?? detect(sms.message); + const encoding = options.encoding; const segments = splitMessage(sms.message, { encoding, reference: deps.reference }); - const refused = checkSegments(allowed, segments.length); + const refused = checkSegments(sms.maxSegments ?? maxSegments, segments.length); if (refused) return unsent(refused); @@ -205,7 +252,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise deps.send({ cmdName: 'submit_sm', - params: submitSmParams(sms, segment, { encoding, messagingMode: mode.messagingMode, multipart }), + params: submitSmParams(sms, segment, { encoding, messagingMode: options.messagingMode, multipart }), }))); return collectSent(sent, deps.respIdNotation); diff --git a/src/sms.ts b/src/sms.ts index de92d43..4635421 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -5,6 +5,7 @@ import type { Result, VoidResult } from './result.ts'; import type { Session } from './session.ts'; import { UnansweredError } from './unanswered-error.ts'; import { consts } from './defs/constants.ts'; +import { messageClassOf } from './defs/encodings.ts'; import { receiptCodes, transientStates } from './dlr.ts'; import { smppDate } from './message.ts'; import { respIdParams, segmentId } from './sms-id.ts'; @@ -35,6 +36,7 @@ export type Sms = { */ answeredOnArrival: boolean; dlr: boolean; + /** GSM 03.38 message class 0: shown on arrival and not stored. */ flash: boolean; from: string; message: string; @@ -71,6 +73,9 @@ export type SmsHandlers = { send: (input: PduObjectInput) => Promise>; }; +/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ +const immediateDisplayClass = 0; + export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { const first = input.pduObjs[0]; const registered = first?.params.registered_delivery; @@ -80,7 +85,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { const sms: Sms = { answeredOnArrival: input.answeredAs !== undefined, dlr: typeof registered === 'number' && registered !== 0, - flash: typeof dataCoding === 'number' && (dataCoding & 0xF0) === 0x10, + flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, from: input.from, message: input.message, pduObjs: input.pduObjs, diff --git a/test/encodings.test.ts b/test/encodings.test.ts index 97a08fb..b16b627 100644 --- a/test/encodings.test.ts +++ b/test/encodings.test.ts @@ -122,8 +122,19 @@ describe('encodingByDataCoding()', () => { test('reads the alphabet bits when a message class is present', () => { assert.equal(encodingByDataCoding(0x10), 'ASCII'); + assert.equal(encodingByDataCoding(0x11), 'ASCII'); assert.equal(encodingByDataCoding(0x18), 'UCS2'); + assert.equal(encodingByDataCoding(0x1A), 'UCS2'); assert.equal(encodingByDataCoding(0xF0), 'ASCII'); + assert.equal(encodingByDataCoding(0xF1), 'ASCII'); + }); + + // The compressed and automatic-deletion groups put the alphabet where the plain one does. + test('reads them in the compressed and automatic-deletion groups too', () => { + assert.equal(encodingByDataCoding(0x30), 'ASCII'); + assert.equal(encodingByDataCoding(0x38), 'UCS2'); + assert.equal(encodingByDataCoding(0x54), 'LATIN1'); + assert.equal(encodingByDataCoding(0x58), 'UCS2'); }); test('resolves the 8-bit binary codings to the codec that keeps every octet', () => { @@ -135,5 +146,7 @@ describe('encodingByDataCoding()', () => { test('falls back to ASCII for alphabets it has no codec for', () => { assert.equal(encodingByDataCoding(0x05), 'ASCII'); assert.equal(encodingByDataCoding(0x0E), 'ASCII'); + // No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table. + assert.equal(encodingByDataCoding(0x48), 'ASCII'); }); }); diff --git a/test/message-class.test.ts b/test/message-class.test.ts new file mode 100644 index 0000000..24f099c --- /dev/null +++ b/test/message-class.test.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import type { PduObjectInput } from '../src/pdu.ts'; +import type { SendSmsDeps } from '../src/send-sms.ts'; +import type { Session } from '../src/session.ts'; +import type { Sms } from '../src/sms.ts'; +import type { TestContext } from 'node:test'; +import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; +import { client } from '../src/client.ts'; +import { closeAfter } from './teardown.ts'; +import { messageClassOf } from '../src/defs/encodings.ts'; +import { paramNumber } from '../src/defs/types.ts'; +import { pduToObj } from '../src/pdu.ts'; +import { server } from '../src/server.ts'; +import { silentLog } from '../src/log.ts'; +import { submitSms } from '../src/send-sms.ts'; + +const from = '46701113311'; +const to = '46709771337'; + +type MessagePeer = { + /** Every message the server session was handed, in arrival order. */ + received: Sms[]; + session: Session; +}; + +/** A server that answers every message, and a client to write raw submit_sm PDUs at it. */ +async function messagesInto(t: TestContext): Promise { + const received: Sms[] = []; + 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', sms => { + received.push(sms); + + return sms.sendResp(); + })); + + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); + assert.ok(connected.session); + closeAfter(t, connected.session); + + return { received, session: connected.session }; +} + +function dataCodingsOf(octets: Buffer[]): number[] { + return octets.map(pdu => { + const { pduObj } = pduToObj(pdu); + + assert.ok(pduObj); + + return paramNumber(pduObj.params.data_coding, 0); + }); +} + +/** A send that records what it is handed, so a refusal shows up as an empty attempt log. */ +function recordingDeps(attempts: PduObjectInput[]): SendSmsDeps { + return { + log: silentLog, + reference: 1, + send: input => { + attempts.push(input); + + return Promise.resolve({ err: new Error('the recording peer never answers') }); + }, + }; +} + +describe('messageClassOf()', () => { + test('reads the class out of both of the coding groups that carry one', () => { + const classes: [number, number | undefined][] = [ + [0x10, 0], [0x11, 1], [0x12, 2], [0x13, 3], + [0x14, 0], [0x18, 0], [0x1B, 3], + [0xF0, 0], [0xF1, 1], [0xF2, 2], [0xF3, 3], + [0xF4, 0], [0xF7, 3], + ]; + + for (const [dataCoding, messageClass] of classes) { + assert.equal(messageClassOf(dataCoding), messageClass, dataCoding.toString(16)); + } + }); + + // Bit 5 marks the text compressed and bits 7-6 mark it for automatic deletion; neither moves the + // class bits, and 01xx is coded exactly as 00xx. + test('reads the class of a compressed message and of the automatic-deletion group', () => { + const classes: [number, number][] = [[0x30, 0], [0x33, 3], [0x50, 0], [0x53, 3], [0x70, 0], [0x78, 0]]; + + for (const [dataCoding, messageClass] of classes) { + assert.equal(messageClassOf(dataCoding), messageClass, dataCoding.toString(16)); + } + }); + + test('reports no class where the coding group defines none', () => { + // Bit 4 clear below 0x80, then the reserved, message-waiting and reserved groups above it. + for (const dataCoding of [0x00, 0x01, 0x03, 0x08, 0x0F, 0x20, 0x40, 0x4F, 0x80, 0xC0, 0xD0, 0xE0, 0xEF]) { + assert.equal(messageClassOf(dataCoding), undefined, dataCoding.toString(16)); + } + }); +}); + +describe('an inbound message', () => { + test('is flash for message class 0 alone, in either coding group', async t => { + const peer = await messagesInto(t); + // 0.4.0 read 0x10 and nothing else; this branch read 0x10-0x1F, so the three stored classes + // arrived as flash and the whole 0xF0 group did not (interop-tests/findings/02-smppsim.md, C17). + const codings: [number, boolean][] = [ + [0x00, false], [0x03, false], [0x08, false], + [0x10, true], [0x11, false], [0x12, false], [0x13, false], + [0x14, true], [0x18, true], [0x30, true], [0x50, true], + [0xF0, true], [0xF1, false], [0xF2, false], [0xF3, false], [0xF4, true], + ]; + + for (const [dataCoding] of codings) { + const sent = await peer.session.send({ + cmdName: 'submit_sm', + params: { + data_coding: dataCoding, + destination_addr: to, + short_message: 'hi', + source_addr: from, + }, + }); + + assert.equal(sent.err, undefined, dataCoding.toString(16)); + } + + assert.deepEqual(peer.received.map(sms => sms.flash), codings.map(([, flash]) => flash)); + assert.deepEqual(peer.received.map(sms => sms.message), codings.map(() => 'hi')); + }); + + test('keeps the alphabet its class group declares, so a flash UCS2 message still reads as UCS2', async t => { + const peer = await messagesInto(t); + const sent = await peer.session.send({ + cmdName: 'submit_sm', + params: { + data_coding: 0x18, + destination_addr: to, + short_message: 'تست', + source_addr: from, + }, + }); + + const [sms] = peer.received; + + assert.equal(sent.err, undefined); + assert.ok(sms); + assert.equal(sms.message, 'تست'); + assert.equal(sms.flash, true); + }); +}); + +describe('sendSms() flash', () => { + test('writes the message class into data_coding beside the alphabet, never over it', async t => { + const smsc = await dummySmsc(t); + const session = await bindToSmsc(t, smsc.port, { reconnect: false }); + const sends = [ + { from, message: 'Hello world', to }, + { flash: true, from, message: 'Hello world', to }, + { from, message: 'تست', to }, + { flash: true, from, message: 'تست', to }, + { encoding: 'LATIN1', from, message: 'Hello world', to }, + ] as const; + + for (const send of sends) { + assert.equal((await session.sendSms(send)).err, undefined, send.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]); + }); + + test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => { + const attempts: PduObjectInput[] = []; + const deps = recordingDeps(attempts); + const sent = await submitSms(deps, { encoding: 'LATIN1', flash: true, from, message: 'Hello world', to }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /flash/); + assert.match(sent.err.message, /Latin-1/); + assert.deepEqual(sent.pduObjs, []); + assert.deepEqual(sent.smsIds, []); + assert.equal(sent.unanswered, 0); + assert.equal(attempts.length, 0, 'a refused flash message puts nothing on the wire'); + + // Either half alone is fine; it is only the pair that has nowhere to go. + const latin1 = await submitSms(deps, { encoding: 'LATIN1', from, message: 'Hello world', to }); + const flash = await submitSms(deps, { flash: true, from, message: 'Hello world', to }); + + assert.equal(latin1.err?.message, 'the recording peer never answers'); + assert.equal(flash.err?.message, 'the recording peer never answers'); + assert.equal(attempts.length, 2); + }); + + // FLASH named data_coding 0x10 among the alphabets, so the class was reachable through the option + // that chooses a charset — and a non-GSM body sent that way was flattened to spaces, unrefused. + test('refuses the message class posing as an alphabet, naming the option that means it', async () => { + const attempts: PduObjectInput[] = []; + const sent = await submitSms(recordingDeps(attempts), { encoding: 'FLASH', from, message: 'Hello world', to }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /encoding FLASH is a message class/); + assert.match(sent.err.message, /flash: true/); + assert.equal(attempts.length, 0); + }); + + test('refuses an encoding naming no alphabet rather than throwing out of the send', async () => { + const attempts: PduObjectInput[] = []; + const deps = recordingDeps(attempts); + + for (const encoding of ['utf8', 'ascii', 8, {}]) { + const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to }); + + assert.ok(sent.err instanceof Error, JSON.stringify(encoding)); + assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/); + assert.deepEqual(sent.smsIds, []); + } + + assert.equal(attempts.length, 0, 'a refused encoding puts nothing on the wire'); + }); +}); diff --git a/todo.md b/todo.md index 352331d..301c0f5 100644 --- a/todo.md +++ b/todo.md @@ -120,11 +120,28 @@ session message is a change to every call site. 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. **This one is 1.0.0-or-never** — after release it needs a major version. -- [ ] `sms.flash` is true only for the 0x10 coding group, never for 0xF0. GSM 03.38 gives 0xF0–0xF3 - as data coding plus message class, where class 0 is immediate display — flash. `encodings.ts` - already reads the 0xF0 group for the alphabet; `sms.ts` does not read it for flash. SMPPSim - observed and recorded it as not-flash (`findings/02-smppsim.md`, C17), so it is measured, not - suspected. Either read the group or say in the README that `flash` means the 0x10 spelling. +- [ ] **`bitCount()`, `encodeMessage()` and `splitMessage()` throw on an encoding name they have no + codec for**, where `sendSms()` now refuses one by name. All three are value-exported, so this + is the published surface, one door over from the one [#95](https://github.com/larvit/larvitsmpp/pull/95) + closed — and that PR sharpened the edge, since `encodeMessage(msg, 'FLASH')` used to encode + quietly and now throws out of the codec table. `decodeMessage()` is unaffected; it resolves + through `encodingByDataCoding()`. Tag-relevant because of the fix shape: guarding them means + either a `Result` signature on three published functions, which needs a major version after + the tag, or a documented deviation saying a typed caller cannot get here. Maintainer's call. + Raised by the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95), + 2026-09-09. +- [ ] **A concatenated Latin-1 segment is 159 octets, and an SMS carries 140.** `segmentUnits` in + `message.ts` budgets 153 for everything that is not UCS2, which is right for GSM 7-bit alone — + the SMSC packs 153 septets into 134 octets. Latin-1 is 8-bit and never packed, so a long + `encoding: 'LATIN1'` message goes out with segments no SMSC can carry. Same family as the + 0.4.0 "Short segments" row, in the other direction; goal 1 owns it, so it wants fixing before + the tag. One line — 134 octets for both unpacked alphabets — plus a regression test. Raised by + the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95), 2026-09-09. +- [ ] **The interop suite still asserts the defect [#95](https://github.com/larvit/larvitsmpp/pull/95) + fixed.** `interop-tests/smppsim.test.ts:621`, `a raw submit_sm with data_coding 0xF0 is not + read as flash`, fails on the next `./interop-tests/run.py smppsim`; nothing in CI runs that + suite, so it fails silently until someone does. The test name and its assertion both need + inverting, and the C17 row in `findings/02-smppsim.md` names that sub-test, so it follows. - [ ] Tag `v1.0.0` to publish. - [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not something CI should do.