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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user