Read a delivery receipt's body as the octets that arrived (#81)

* Regression tests for a receipt body read under a data_coding it is not written in

* Read a delivery receipt's body as the octets that arrived, not by its data_coding

* Assert SMPPSim's UCS2 receipt body parses like every other segment's

* Record the receipt-body defect as fixed

* Retain a multipart segment's octets once, not twice

* Sort the new type import into its file's order
This commit is contained in:
2026-09-05 22:37:05 +02:00
committed by GitHub
parent ea42bc6d20
commit 45d2f5548e
19 changed files with 300 additions and 162 deletions
+9
View File
@@ -265,3 +265,12 @@ export function commandNameById(id: number): CommandName | undefined {
return isCommandName(command) ? command : undefined;
}
/** The response SMPP pairs with a request command, where it has one. */
export function respNameFor(cmdName: CommandName | undefined): CommandName | undefined {
if (cmdName === undefined) return undefined;
const respName = `${cmdName}_resp`;
return isCommandName(respName) ? respName : undefined;
}
+11 -10
View File
@@ -2,10 +2,11 @@ import type { MessageState } from './defs/constants.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts';
import { consts, constsById, messageTypeOf } from './defs/constants.ts';
import { decodeMessage } from './message.ts';
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
import { encodings } from './defs/encodings.ts';
import { normaliseSmsId } from './sms-id.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { udhLength } from './udh.ts';
/**
* The seven-character status codes carried in a receipt's `stat:` field, mapped to the
@@ -154,17 +155,17 @@ function messageType(pduObj: PduObject): MessageType {
return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt';
}
/** A UDH-carrying short_message reaches here as a buffer, header and all. */
/** SMPP 3.4 Appendix B makes a receipt fixed text, so its octets are read as octets, not decoded. */
function receiptBody(pduObj: PduObject): string {
const message = pduObj.params.short_message;
const octets = pduObj.shortMessageOctets;
if (!Buffer.isBuffer(message)) return paramText(message);
if (octets === undefined) return paramText(pduObj.params.short_message);
return decodeMessage(
message,
paramNumber(pduObj.params.data_coding, 0),
paramNumber(pduObj.params.esm_class, 0),
).message;
const body = hasUdh(paramNumber(pduObj.params.esm_class, 0))
? octets.subarray(udhLength(octets))
: octets;
return encodings.LATIN1.decode(body);
}
function receiptId(
+2 -1
View File
@@ -12,13 +12,14 @@ export { types } from './defs/types.ts';
export {
isCommand,
isResp,
maxPduLength,
maxSeqNr,
objToPdu,
pduReturn,
pduToObj,
} from './pdu.ts';
export { maxPduLength } from './pdu-refusal.ts';
export {
bitCount,
decodeMessage,
+5 -4
View File
@@ -1,7 +1,8 @@
import type { Result } from './result.ts';
import type { EncodingName } from './defs/encodings.ts';
import { hasUdh } from './defs/constants.ts';
import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
import { hasUdh } from './defs/constants.ts';
import { udhLength } from './udh.ts';
/** A single SMS carries 1120 bits, whatever the alphabet. */
const singleMessageBits = 1120;
@@ -37,11 +38,11 @@ export function decodeMessage(
return { message: encodings[encoding].decode(buffer), udh: undefined };
}
const udhLength = (buffer[0] ?? 0) + 1;
const headerLength = udhLength(buffer);
return {
message: encodings[encoding].decode(buffer.subarray(udhLength)),
udh: buffer.subarray(0, udhLength),
message: encodings[encoding].decode(buffer.subarray(headerLength)),
udh: buffer.subarray(0, headerLength),
};
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Result } from './result.ts';
import { framingRefusal } from './pdu.ts';
import { framingRefusal } from './pdu-refusal.ts';
/**
* Cuts a byte stream into whole PDUs.
+57
View File
@@ -0,0 +1,57 @@
import type { CommandName } from './defs/commands.ts';
import type { ErrorName } from './defs/errors.ts';
import { respNameFor } from './defs/commands.ts';
/** A hostile peer must not be able to make us allocate arbitrarily. */
export const maxPduLength = 1024 * 1024;
/** The 16 octets a framed PDU always has, whatever its body turns out to be. */
export type PduHeader = {
cmdId: number;
cmdLength: number;
cmdName: CommandName | undefined;
cmdStatusId: number;
seqNr: number;
};
/** Which part of a PDU the codec could not read. */
export type PduRefusalReason = 'body' | 'command' | 'tlvs';
/** A PDU refused with the stream still in sync, so only this one PDU is lost. */
export class PduRefusedError extends Error {
readonly header: PduHeader;
readonly reason: PduRefusalReason;
constructor(header: PduHeader, reason: PduRefusalReason, cause: Error) {
const named = header.cmdName ?? `command id ${String(header.cmdId)}`;
super(`Refused ${named} with seqNr ${String(header.seqNr)}: ${cause.message}`, { cause });
this.header = header;
this.name = 'PduRefusedError';
this.reason = reason;
}
}
// ESME_RINVTLVSTREAM is SMPP 5.0's name for 0xC0, which SMPP 3.4 spells ESME_RINVOPTPARSTREAM.
const refusalStatus = {
body: 'ESME_RINVCMDLEN',
command: 'ESME_RINVCMDID',
tlvs: 'ESME_RINVTLVSTREAM',
} as const satisfies Record<PduRefusalReason, ErrorName>;
/** Why a command_length cannot frame a stream: past it nothing can say where the next PDU starts. */
export function framingRefusal(cmdLength: number): Error | undefined {
if (cmdLength < 16 || cmdLength > maxPduLength) {
return new Error(`Refusing a cmd_length of ${String(cmdLength)}`);
}
return undefined;
}
/** SMPP 3.4 4.3: a PDU whose command has no response of its own is refused with generic_nack. */
export function refusalAnswer(refused: PduRefusedError): { cmdName: CommandName; cmdStatus: ErrorName } {
return {
cmdName: respNameFor(refused.header.cmdName) ?? 'generic_nack',
cmdStatus: refusalStatus[refused.reason],
};
}
+2 -1
View File
@@ -3,7 +3,8 @@ import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net';
import type { VoidResult } from './result.ts';
import { PduFramer } from './pdu-framer.ts';
import { PduRefusedError, pduToObj } from './pdu.ts';
import { PduRefusedError } from './pdu-refusal.ts';
import { pduToObj } from './pdu.ts';
export type PduTransportOptions = {
log: SmppLog;
+9 -67
View File
@@ -1,9 +1,11 @@
import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduHeader } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts';
import type { Tlv } from './defs/tlvs.ts';
import { cmds, commandNameById, isCommandName } from './defs/commands.ts';
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { consts, hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeMessage } from './message.ts';
import { detect, encodingByDataCoding } from './defs/encodings.ts';
@@ -17,18 +19,6 @@ export const maxSeqNr = 2147483646;
/** What the field holds. Read and echoed in full, because peers do write above the spec's range. */
const maxWireSeqNr = 0xFFFFFFFF;
/** A hostile peer must not be able to make us allocate arbitrarily. */
export const maxPduLength = 1024 * 1024;
/** Why a command_length cannot frame a stream: past it nothing can say where the next PDU starts. */
export function framingRefusal(cmdLength: number): Error | undefined {
if (cmdLength < 16 || cmdLength > maxPduLength) {
return new Error(`Refusing a cmd_length of ${String(cmdLength)}`);
}
return undefined;
}
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
@@ -55,60 +45,11 @@ export type PduObject = {
cmdStatusId: number;
params: Record<string, ParamValue>;
seqNr: number;
/** short_message as it arrived, whatever data_coding turned `params.short_message` into. */
shortMessageOctets: Buffer | undefined;
tlvs: Record<string, Tlv>;
};
/** The 16 octets a framed PDU always has, whatever its body turns out to be. */
export type PduHeader = {
cmdId: number;
cmdLength: number;
cmdName: CommandName | undefined;
cmdStatusId: number;
seqNr: number;
};
/** Which part of a PDU the codec could not read. */
export type PduRefusalReason = 'body' | 'command' | 'tlvs';
/** A PDU refused with the stream still in sync, so only this one PDU is lost. */
export class PduRefusedError extends Error {
readonly header: PduHeader;
readonly reason: PduRefusalReason;
constructor(header: PduHeader, reason: PduRefusalReason, cause: Error) {
const named = header.cmdName ?? `command id ${String(header.cmdId)}`;
super(`Refused ${named} with seqNr ${String(header.seqNr)}: ${cause.message}`, { cause });
this.header = header;
this.name = 'PduRefusedError';
this.reason = reason;
}
}
// ESME_RINVTLVSTREAM is SMPP 5.0's name for 0xC0, which SMPP 3.4 spells ESME_RINVOPTPARSTREAM.
const refusalStatus = {
body: 'ESME_RINVCMDLEN',
command: 'ESME_RINVCMDID',
tlvs: 'ESME_RINVTLVSTREAM',
} as const satisfies Record<PduRefusalReason, ErrorName>;
/** The response SMPP pairs with a request command, where it has one. */
function respNameFor(cmdName: CommandName | undefined): CommandName | undefined {
if (cmdName === undefined) return undefined;
const respName = `${cmdName}_resp`;
return isCommandName(respName) ? respName : undefined;
}
/** SMPP 3.4 4.3: a PDU whose command has no response of its own is refused with generic_nack. */
export function refusalAnswer(refused: PduRefusedError): { cmdName: CommandName; cmdStatus: ErrorName } {
return {
cmdName: respNameFor(refused.header.cmdName) ?? 'generic_nack',
cmdStatus: refusalStatus[refused.reason],
};
}
const respBit = 0x80000000;
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
@@ -391,11 +332,11 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
const params = read.params;
const message = params.short_message;
const esmClass = paramNumber(params.esm_class, 0);
const octets = Buffer.isBuffer(message) ? message : undefined;
// A message carrying a UDH stays a buffer; the session needs the header intact to reassemble.
if (Buffer.isBuffer(message) && !hasUdh(esmClass)) {
params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message;
if (octets && !hasUdh(paramNumber(params.esm_class, 0))) {
params.short_message = decodeMessage(octets, paramNumber(params.data_coding, 0)).message;
}
return {
@@ -408,6 +349,7 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
cmdStatusId,
params,
seqNr,
shortMessageOctets: octets,
tlvs: parsed.tlvs,
},
};
+6 -1
View File
@@ -39,7 +39,12 @@ function detach(pduObj: PduObject): PduObject {
: tlv;
}
return { ...pduObj, params, tlvs };
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
const octets = Buffer.isBuffer(params.short_message)
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
}
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
+4 -2
View File
@@ -1,7 +1,8 @@
import type { ErrorName } from './defs/errors.ts';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, PduRefusedError, TlvInput } from './pdu.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
@@ -18,7 +19,8 @@ import { leftOf } from './idle-waiters.ts';
import { errorFrom } from './error-from.ts';
import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { isResp, objToPdu, pduReturn, refusalAnswer } from './pdu.ts';
import { isResp, objToPdu, pduReturn } from './pdu.ts';
import { refusalAnswer } from './pdu-refusal.ts';
import { guardedLog } from './log.ts';
import { submitSms, unsent } from './send-sms.ts';
import { ConcatReference } from './udh.ts';
+5
View File
@@ -9,6 +9,11 @@ export class ConcatReference {
}
}
/** A user data header is as long as its first octet says, that octet included. */
export function udhLength(message: Buffer): number {
return (message[0] ?? 0) + 1;
}
export type ConcatInfo = {
part: number;
reference: number;