Refuse a body the PDU's own data_coding cannot carry (#98)

* Regression tests for a body the PDU's own data_coding cannot carry

* Refuse a body the PDU's own data_coding cannot carry, in the codec both send paths build through

* Record the codec's body refusal, and drop two todo entries it and the interop run close

* Regression tests for the aliased body TLV and the data_coding short_message settles

* Find the body TLV by tag id, leave data_coding to the field that will be read, and correct the record

* Record the two architecture findings this change does not close

* Regression tests for a duplicated body tag and a short_message the wire never carries

* Resolve every body TLV against the field that will be read, and move TLV writing to its table

* Regression tests for a body only the TLV carries and an empty short_message

* Let only octets the command writes settle the alphabet, and stop shadowing the TLV table
This commit is contained in:
2026-09-09 19:33:36 +02:00
committed by GitHub
parent c06cc0648b
commit ef13290230
9 changed files with 476 additions and 102 deletions
+37 -1
View File
@@ -109,7 +109,7 @@ src/
constants.ts consts + constsById, and the SMPP version constants constants.ts consts + constsById, and the SMPP version constants
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
errors.ts errors + errorsById (ESME_*) errors.ts errors + errorsById (ESME_*)
tlvs.ts TLV definitions, tlvsById tlvs.ts TLV definitions, tlvsById, the input shape, and writing a TLV stream
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
``` ```
@@ -622,6 +622,42 @@ Grouped by what each one constrains.
the one input a per-character reading passes and the encoder then writes as the extended character the one input a per-character reading passes and the encoder then writes as the extended character
— the only composition in any of the three codecs, and not a character a message is written in. — the only composition in any of the three codecs, and not a character a message is written in.
- **A string body is written in the alphabet its own `data_coding` names, and one that alphabet
cannot carry is refused by the codec — `message_payload` on the same terms as `short_message`.**
Maintainer's call, 2026-09-09, from the architecture review of
[#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's
own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう`
returned `42 44 46``"BDF"` — reported as built, while a string `message_payload` was cut to its
low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above.
The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given
under any `data_coding`, which is what keeps goal 6's escape hatch open — the raw UDH, 8-bit binary
and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string
with no `data_coding` is untouched, detection carrying every character it was picked for. The
guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the
character, its code point and its index said once for both refusals. It is reached through
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
`encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the
alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it,
since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because
`splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment
could name is not the one in the message. The TLV is encoded rather than merely checked because
`data_coding` names the alphabet of the body wherever it is carried — that is how
`messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one
— so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would
close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the
order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will
be read under — and a `short_message` on a command whose table declares none is ignored here as
`writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the
same input rather than three. A `data_coding` on a command that declares no such field is honoured
the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in.
Every entry carrying the payload tag is resolved, by tag id rather
than by record key, since `tagIdOf()` lets a caller name it anything and a spelling that escaped
the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string
`message_payload` outright and demanding
octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
own definition.
### 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,
+7
View File
@@ -577,6 +577,13 @@ same for the GSM 03.38 message class: `0` for the flash class `sms.flash` alread
and `3` for the ME-, SIM- and TE-specific ones, and `undefined` where that `data_coding`'s coding and `3` for the ME-, SIM- and TE-specific ones, and `undefined` where that `data_coding`'s coding
group carries no class at all. group carries no class at all.
Building a PDU is the mirror: a string `short_message`, or a string `message_payload`, is encoded in
the alphabet the PDU's own `data_coding` names, detected from the text where you name none, and a
`data_coding` whose alphabet cannot carry one of its characters is refused, naming the character, its
code point and where it is. A `Buffer` goes out exactly as given under any `data_coding`, which is
how binary payloads, hand-built user data headers and deliberately malformed bodies are sent.
`session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
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; `smppTime.encode(value)`
+7
View File
@@ -123,6 +123,13 @@ export function detect(value: string): EncodingName {
export type Unencodable = { char: string; index: number }; export type Unencodable = { char: string; index: number };
/** What an alphabet could not carry, said the one way: the character, its code point and its index. */
export function unencodableText(at: Unencodable): string {
const point = (at.char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0');
return `${JSON.stringify(at.char)} (U+${point}) at index ${String(at.index)}`;
}
/** The first character `encoding` cannot carry, or undefined where it carries every one of them. */ /** The first character `encoding` cannot carry, or undefined where it carries every one of them. */
export function unencodable(message: string, encoding: EncodingName): Unencodable | undefined { export function unencodable(message: string, encoding: EncodingName): Unencodable | undefined {
const codec = encodings[encoding]; const codec = encodings[encoding];
+58
View File
@@ -1,4 +1,5 @@
import type { ParamValue, WireType } from './types.ts'; import type { ParamValue, WireType } from './types.ts';
import type { Result } from '../result.ts';
import { tlv } from './types.ts'; import { tlv } from './types.ts';
export type TlvDefinition = { export type TlvDefinition = {
@@ -104,3 +105,60 @@ export type Tlv = {
tagName: string | undefined; tagName: string | undefined;
tagValue: ParamValue; tagValue: ParamValue;
}; };
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
tagValue: ParamValue;
};
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
const tagId = input.tagId ?? tlvs[name]?.id;
if (tagId === undefined) {
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
}
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
}
return { tagId };
}
/** Each TLV as its four octet header and the value the tag's own wire type writes. */
export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
for (const [name, input] of Object.entries(inputs ?? {})) {
const tag = tagIdOf(name, input);
if (tag.err) return { err: tag.err };
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
const sized = type.size(input.tagValue);
if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
}
if (sized.size > 0xffff) {
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
}
const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tag.tagId, 0);
chunk.writeUInt16BE(sized.size, 2);
const written = type.write(input.tagValue, chunk, 4);
if (written.err) {
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
}
chunks.push(chunk);
}
return { chunks };
}
+21 -2
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 } from './defs/encodings.ts'; import { detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
import { hasUdh } from './defs/constants.ts'; import { consts, 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. */
@@ -27,6 +27,25 @@ export function encodeMessage(
return { buffer: encodings[resolved].encode(message), encoding: resolved }; return { buffer: encodings[resolved].encode(message), encoding: resolved };
} }
/** A message body as octets, under the alphabet `dataCoding` resolves to, or one detected for it. */
export function encodeBody(
text: string,
dataCoding: number | undefined,
): Result<{ buffer: Buffer; dataCoding: number }> {
if (dataCoding === undefined) {
const detected = encodeMessage(text);
return { buffer: detected.buffer, dataCoding: consts.ENCODING[detected.encoding] };
}
const encoding = encodingByDataCoding(dataCoding);
const lost = unencodable(text, encoding);
return lost
? { err: new Error(`data_coding ${String(dataCoding)} resolves to ${encoding}, which cannot carry ${unencodableText(lost)}; pass a Buffer of octets, or a data_coding whose alphabet carries them`) }
: { buffer: encodings[encoding].encode(text), dataCoding };
}
export function decodeMessage( export function decodeMessage(
buffer: Buffer, buffer: Buffer,
dataCoding: number, dataCoding: number,
+103 -86
View File
@@ -3,15 +3,14 @@ import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduHeader } from './pdu-refusal.ts'; import type { PduHeader } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Tlv } from './defs/tlvs.ts'; import type { Tlv, TlvInput } from './defs/tlvs.ts';
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts'; import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
import { cmds, commandNameById, respNameFor } from './defs/commands.ts'; import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { consts, hasUdh } from './defs/constants.ts'; import { hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeMessage } from './message.ts'; import { decodeMessage, encodeBody } from './message.ts';
import { detect, encodingByDataCoding } from './defs/encodings.ts';
import { errorNameById, errors, isErrorName } from './defs/errors.ts'; import { errorNameById, errors, isErrorName } from './defs/errors.ts';
import { paramNumber } from './defs/types.ts'; import { paramNumber } from './defs/types.ts';
import { tlvDefault, tlvs, tlvsById } from './defs/tlvs.ts'; import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */ /** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
export const maxSeqNr = 2147483646; export const maxSeqNr = 2147483646;
@@ -19,12 +18,6 @@ export const maxSeqNr = 2147483646;
/** What the field holds. Read and echoed in full, because peers do write above the spec's range. */ /** What the field holds. Read and echoed in full, because peers do write above the spec's range. */
const maxWireSeqNr = 0xFFFFFFFF; const maxWireSeqNr = 0xFFFFFFFF;
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
tagValue: ParamValue;
};
export type PduObjectInput<C extends CommandName = CommandName> = { export type PduObjectInput<C extends CommandName = CommandName> = {
cmdName: C; cmdName: C;
cmdStatus?: ErrorName; cmdStatus?: ErrorName;
@@ -53,6 +46,8 @@ export type PduObject = {
tlvs: Record<string, Tlv>; tlvs: Record<string, Tlv>;
}; };
export type { TlvInput };
const respBit = 0x80000000; const respBit = 0x80000000;
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean { export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
@@ -71,44 +66,98 @@ export function isCommand<C extends CommandName>(
return pduObj.cmdName === cmdName; return pduObj.cmdName === cmdName;
} }
function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> { type ResolvedBody = {
const tagId = input.tagId ?? tlvs[name]?.id; params: Record<string, ParamValue | undefined>;
tlvs: Record<string, TlvInput> | undefined;
if (tagId === undefined) {
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
}
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
}
return { tagId };
}
/** Encoding a string short_message settles data_coding and sm_length; a buffer settles sm_length. */
function resolveShortMessage(
params: Record<string, ParamValue | undefined>,
): Record<string, ParamValue | undefined> {
const message = params.short_message;
if (Buffer.isBuffer(message)) {
return params.sm_length === undefined
? { ...params, sm_length: message.length }
: { ...params };
}
if (typeof message !== 'string') return { ...params };
const dataCoding = params.data_coding;
const encoding = typeof dataCoding === 'number' ? encodingByDataCoding(dataCoding) : detect(message);
const encoded = encodeMessage(message, encoding);
return {
...params,
data_coding: typeof dataCoding === 'number' ? dataCoding : consts.ENCODING[encoded.encoding],
short_message: encoded.buffer,
sm_length: encoded.buffer.length,
}; };
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
}
type CarriedBody = { name: string; text: string; tlv: TlvInput };
/** Every entry carrying body text, under whatever names their tagIds are keyed to. */
function carriedBodies(input: Record<string, TlvInput> | undefined): CarriedBody[] {
const carried: CarriedBody[] = [];
for (const [name, tlv] of Object.entries(input ?? {})) {
const tag = tagIdOf(name, tlv);
if (!tag.err && tag.tagId === tlvs.message_payload.id && typeof tlv.tagValue === 'string') {
carried.push({ name, text: tlv.tagValue, tlv });
}
}
return carried;
}
/** messageOctets() reads short_message wherever it holds an octet, and the TLV only where it does not. */
function carriesOctets(value: ParamValue | undefined): boolean {
return Buffer.isBuffer(value) && value.length > 0;
}
/** The short_message the command's own table will write, since writeParams() ignores any other. */
function writtenBody(definition: CommandDefinition, value: ParamValue | undefined): ParamValue | undefined {
return definition.params?.short_message === undefined ? undefined : value;
}
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
function resolveCarried(
resolved: ResolvedBody,
inputs: Record<string, TlvInput> | undefined,
dataCoding: number | undefined,
settles: boolean,
): VoidResult {
for (const carried of carriedBodies(inputs)) {
const encoded = encodeBody(carried.text, dataCoding);
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
if (settles) resolved.params.data_coding = encoded.dataCoding;
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
}
return {};
}
/** data_coding names the alphabet of the body, and short_message settles it where it carries octets. */
function resolveBody(
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
definition: CommandDefinition,
): Result<ResolvedBody> {
const message = writtenBody(definition, params.short_message);
const resolved: ResolvedBody = { params: { ...params }, tlvs };
if (Buffer.isBuffer(message) && params.sm_length === undefined) {
resolved.params.sm_length = message.length;
}
if (typeof message === 'string') {
const encoded = encodeBody(message, codingOf(params));
if (encoded.err) {
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
}
if (carriesOctets(encoded.buffer)) resolved.params.data_coding = encoded.dataCoding;
resolved.params.short_message = encoded.buffer;
resolved.params.sm_length = encoded.buffer.length;
}
// Only octets the command's own table will write can settle the alphabet the PDU declares.
const settles = !carriesOctets(writtenBody(definition, resolved.params.short_message));
const carried = resolveCarried(
resolved,
tlvs,
settles ? codingOf(params) : codingOf(resolved.params),
settles,
);
return carried.err ? { err: carried.err } : resolved;
} }
function writeParams( function writeParams(
@@ -139,42 +188,6 @@ function writeParams(
return { chunks }; return { chunks };
} }
function writeTlvs(tlvs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
for (const [name, tlv] of Object.entries(tlvs ?? {})) {
const tag = tagIdOf(name, tlv);
if (tag.err) return { err: tag.err };
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
const sized = type.size(tlv.tagValue);
if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
}
if (sized.size > 0xffff) {
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
}
const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tag.tagId, 0);
chunk.writeUInt16BE(sized.size, 2);
const written = type.write(tlv.tagValue, chunk, 4);
if (written.err) {
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
}
chunks.push(chunk);
}
return { chunks };
}
/** /**
* SMPP 3.4: a response reporting a failure carries no body, so its fields are not "unused but * SMPP 3.4: a response reporting a failure carries no body, so its fields are not "unused but
* present" — they are absent, and a peer that reads them anyway reads past the end of the PDU. * present" — they are absent, and a peer that reads them anyway reads past the end of the PDU.
@@ -188,11 +201,15 @@ function buildBody(
): Result<{ body: Buffer }> { ): Result<{ body: Buffer }> {
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) }; if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
const written = writeParams(definition, resolveShortMessage(params), cmdName); const body = resolveBody(params, tlvs, definition);
if (body.err) return { err: body.err };
const written = writeParams(definition, body.params, cmdName);
if (written.err) return { err: written.err }; if (written.err) return { err: written.err };
const writtenTlvs = writeTlvs(tlvs); const writtenTlvs = writeTlvs(body.tlvs);
if (writtenTlvs.err) return { err: writtenTlvs.err }; if (writtenTlvs.err) return { err: writtenTlvs.err };
+2 -4
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 } from './defs/encodings.ts'; import { 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';
@@ -168,9 +168,7 @@ function refusedEncoding(encoding: unknown): Error {
} }
function refusedText(encoding: EncodingName, at: Unencodable): Error { function refusedText(encoding: EncodingName, at: Unencodable): Error {
const point = (at.char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0'); return new Error(`encoding ${encoding} cannot carry ${unencodableText(at)}; name UCS2 or leave encoding out`);
return new Error(`encoding ${encoding} cannot carry ${JSON.stringify(at.char)} (U+${point}) at index ${String(at.index)}; name UCS2 or leave encoding out`);
} }
/** An alphabet the caller named has to carry the message; the one detect() picks always does. */ /** An alphabet the caller named has to carry the message; the one detect() picks always does. */
+234 -1
View File
@@ -4,8 +4,9 @@ import type { PduObjectInput } from '../src/pdu.ts';
import type { SendSmsDeps, SendSmsInput } from '../src/send-sms.ts'; import type { SendSmsDeps, SendSmsInput } from '../src/send-sms.ts';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { decodeMessage } from '../src/message.ts'; import { decodeMessage } from '../src/message.ts';
import { messageOctets } from '../src/message-body.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
import { paramNumber, paramText } from '../src/defs/types.ts'; import { paramNumber, paramText } from '../src/defs/types.ts';
import { pduToObj } from '../src/pdu.ts';
import { silentLog } from '../src/log.ts'; import { silentLog } from '../src/log.ts';
import { submitSms } from '../src/send-sms.ts'; import { submitSms } from '../src/send-sms.ts';
@@ -112,6 +113,238 @@ describe('an alphabet the caller named that cannot carry the message', () => {
}); });
}); });
describe('a body the PDU\'s own data_coding cannot carry', () => {
/** The octets a built PDU carries as its body, wherever the caller put them. */
function bodyOf(built: ReturnType<typeof objToPdu>): Buffer | undefined {
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
return messageOctets(pduObj);
}
// Latin-1 takes the low octet of every code point, so あ (U+3042) went out as 0x42 — the letter B.
test('refuses a string short_message the named alphabet cannot carry, naming the character', () => {
const built = objToPdu({
cmdName: 'submit_sm',
params: { data_coding: 0x03, destination_addr: to, short_message: 'あいう', source_addr: from },
});
assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined);
assert.match(built.err.message, /short_message/);
assert.match(built.err.message, /LATIN1/);
assert.match(built.err.message, /"あ"/);
assert.match(built.err.message, /U\+3042/);
assert.match(built.err.message, /index 0/);
});
// Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space.
test('refuses a string short_message GSM 03.38 has no code for, naming that one', () => {
for (const dataCoding of [0x00, 0x01]) {
const built = objToPdu({
cmdName: 'submit_sm',
params: { data_coding: dataCoding, destination_addr: to, short_message: 'Åsa naïve', source_addr: from },
});
assert.ok(built.err instanceof Error, String(dataCoding));
assert.match(built.err.message, /ASCII/);
assert.match(built.err.message, /"ï"/);
assert.match(built.err.message, /U\+00EF/);
assert.match(built.err.message, /index 6/);
}
});
test('refuses a string message_payload on the same terms as short_message', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
tlvs: { message_payload: { tagValue: 'あいう' } },
});
assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined);
assert.match(built.err.message, /message_payload/);
assert.match(built.err.message, /LATIN1/);
assert.match(built.err.message, /"あ"/);
assert.match(built.err.message, /U\+3042/);
});
test('refuses the body TLV under whatever name the caller keyed its tagId to', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
tlvs: { body: { tagId: 0x0424, tagValue: 'あいう' } },
});
assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined);
assert.match(built.err.message, /"body"/);
assert.match(built.err.message, /LATIN1/);
assert.match(built.err.message, /U\+3042/);
});
test('leaves data_coding to short_message wherever it carries octets, as messageOctets() reads it', () => {
const short = Buffer.from([0xDE, 0xAD]);
const built = objToPdu({
cmdName: 'deliver_sm',
params: { destination_addr: to, short_message: short, source_addr: from },
tlvs: { message_payload: { tagValue: 'あいう' } },
});
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0, 'the TLV must not name an alphabet for octets nothing reads it as');
assert.deepEqual(messageOctets(pduObj), short);
});
test('encodes every entry carrying the body tag, so a second one cannot go out truncated', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x08, destination_addr: to, source_addr: from },
tlvs: { alias: { tagId: 0x0424, tagValue: 'あいう' }, message_payload: { tagValue: 'あいう' } },
});
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const hex = built.buffer.toString('hex');
assert.equal(hex.split('304230443046').length - 1, 2, 'both entries carry the UCS2 octets');
assert.ok(!hex.includes('424446'), 'no entry goes out as the low octets of its code points');
});
test('leaves the alphabet to the body TLV wherever short_message carries no octets', () => {
for (const short of [undefined, '', Buffer.alloc(0)]) {
const built = objToPdu({
cmdName: 'deliver_sm',
params: {
destination_addr: to,
source_addr: from,
...(short === undefined ? {} : { short_message: short }),
},
tlvs: { message_payload: { tagValue: 'あいう' } },
});
const name = short === undefined ? 'absent' : JSON.stringify(short);
assert.equal(built.err, undefined, name);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0x08, name);
assert.equal(messageOctets(pduObj)?.toString('hex'), '304230443046', name);
}
});
test('ignores a short_message on a command that declares none, which the wire never carries', () => {
for (const short of ['x', Buffer.from([0xDE, 0xAD])]) {
const input: PduObjectInput = {
cmdName: 'data_sm',
params: { destination_addr: to, short_message: short, source_addr: from },
tlvs: { message_payload: { tagValue: 'あいう' } },
};
const built = objToPdu(input);
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0x08, JSON.stringify(short));
assert.equal(messageOctets(pduObj)?.toString('hex'), '304230443046');
}
});
test('leaves data_coding at its default where an empty short_message is the whole body', () => {
for (const short of ['', Buffer.alloc(0)]) {
const built = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: short, source_addr: from },
});
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0, JSON.stringify(short));
}
});
test('never refuses a Buffer body, whatever the coding, because that is how binary is sent', () => {
const payload = Buffer.from([0x30, 0x42, 0xC3, 0x28, 0xFF, 0x00]);
for (const dataCoding of [0x00, 0x01, 0x03, 0x04, 0x08, 0xF0]) {
const params = { data_coding: dataCoding, destination_addr: to, source_addr: from };
const short = objToPdu({ cmdName: 'submit_sm', params: { ...params, short_message: payload } });
const carried = objToPdu({
cmdName: 'data_sm',
params,
tlvs: { message_payload: { tagValue: payload } },
});
assert.deepEqual(bodyOf(short), payload, `short_message under data_coding ${String(dataCoding)}`);
assert.deepEqual(bodyOf(carried), payload, `message_payload under data_coding ${String(dataCoding)}`);
}
});
test('never refuses a string the caller named no data_coding for, since detection always fits', () => {
for (const message of ['Hello world', 'Åsa naïve', 'あいう', '😀 beyond the basic plane']) {
const built = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: message, source_addr: from },
});
assert.equal(built.err, undefined, message);
}
});
test('writes a body the coding does carry as the octets that alphabet spells it in', () => {
const bodies: [number, string, string][] = [
[0x00, 'Hello world', '48656c6c6f20776f726c64'],
[0x03, 'Räksmörgås', '52e46b736df67267e573'],
[0x08, 'あいう', '304230443046'],
];
for (const [dataCoding, message, hex] of bodies) {
const params = { data_coding: dataCoding, destination_addr: to, source_addr: from };
const short = objToPdu({ cmdName: 'submit_sm', params: { ...params, short_message: message } });
const carried = objToPdu({
cmdName: 'data_sm',
params,
tlvs: { message_payload: { tagValue: message } },
});
assert.equal(bodyOf(short)?.toString('hex'), hex, `short_message: ${message}`);
assert.equal(bodyOf(carried)?.toString('hex'), hex, `message_payload: ${message}`);
}
});
test('refuses the same body through session.send(), with nothing reaching the socket', async t => {
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const sent = await session.send({
cmdName: 'submit_sm',
params: { data_coding: 0x03, destination_addr: to, short_message: 'あいう', source_addr: from },
});
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /LATIN1/);
assert.deepEqual(smsc.octets, [], 'a body the named alphabet cannot carry never reaches the socket');
});
});
describe('a time no peer can read', () => { describe('a time no peer can read', () => {
const invalid = new Date('nope'); const invalid = new Date('nope');
+14 -15
View File
@@ -120,11 +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.
- [ ] **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. - [ ] 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.
@@ -141,16 +136,20 @@ session message is a change to every call site.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **`objToPdu()` corrupts a string body the same way `sendSms()` used to.** - [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
`resolveShortMessage()` in `pdu.ts` picks the codec off the caller's own `data_coding` and `outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
encodes a string `short_message` with it, so that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
`objToPdu({ params: { data_coding: 3, short_message: 'あいう' } })` returns `42 44 46` every attempt is the same case, and #98 made it a common one. On a down link the caller waits
`"BDF"` — with no error, and `data_coding` 0 flattens to spaces. Same goal 2 defect as the one `responseTimeout` and is told the link failed rather than that the body could not be built —
the `unencodable()` guard closed at `sendSms()`, one layer down and on the published codec. The goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is
guard is the same call on the branch where `data_coding` is a number and the body is a string; the open half. Raised by the architecture review of
the `detect()` branch needs none. Held back only because a refusal there may fail [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
`interop-tests/`, which is being edited elsewhere and cannot be verified from here. Raised by
the architecture review of [#97](https://github.com/larvit/larvitsmpp/pull/97), 2026-09-09. - [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`,
`smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four
concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is
the split. Raised by the architecture review of
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on - [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml` [#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml`