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:
@@ -123,6 +123,13 @@ export function detect(value: string): EncodingName {
|
||||
|
||||
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. */
|
||||
export function unencodable(message: string, encoding: EncodingName): Unencodable | undefined {
|
||||
const codec = encodings[encoding];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ParamValue, WireType } from './types.ts';
|
||||
import type { Result } from '../result.ts';
|
||||
import { tlv } from './types.ts';
|
||||
|
||||
export type TlvDefinition = {
|
||||
@@ -104,3 +105,60 @@ export type Tlv = {
|
||||
tagName: string | undefined;
|
||||
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
@@ -1,7 +1,7 @@
|
||||
import type { Result } from './result.ts';
|
||||
import type { EncodingName } from './defs/encodings.ts';
|
||||
import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
|
||||
import { consts, hasUdh } from './defs/constants.ts';
|
||||
import { udhLength } from './udh.ts';
|
||||
|
||||
/** A single SMS carries 1120 bits, whatever the alphabet. */
|
||||
@@ -27,6 +27,25 @@ export function encodeMessage(
|
||||
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(
|
||||
buffer: Buffer,
|
||||
dataCoding: number,
|
||||
|
||||
+96
-79
@@ -3,15 +3,14 @@ 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 type { Tlv, TlvInput } from './defs/tlvs.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';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { decodeMessage, encodeBody } from './message.ts';
|
||||
import { errorNameById, errors, isErrorName } from './defs/errors.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. */
|
||||
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. */
|
||||
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> = {
|
||||
cmdName: C;
|
||||
cmdStatus?: ErrorName;
|
||||
@@ -53,6 +46,8 @@ export type PduObject = {
|
||||
tlvs: Record<string, Tlv>;
|
||||
};
|
||||
|
||||
export type { TlvInput };
|
||||
|
||||
const respBit = 0x80000000;
|
||||
|
||||
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
|
||||
@@ -71,44 +66,98 @@ export function isCommand<C extends CommandName>(
|
||||
return pduObj.cmdName === cmdName;
|
||||
}
|
||||
|
||||
function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
||||
const tagId = input.tagId ?? tlvs[name]?.id;
|
||||
type ResolvedBody = {
|
||||
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 };
|
||||
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
|
||||
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
type CarriedBody = { name: string; text: string; tlv: TlvInput };
|
||||
|
||||
if (Buffer.isBuffer(message)) {
|
||||
return params.sm_length === undefined
|
||||
? { ...params, sm_length: message.length }
|
||||
: { ...params };
|
||||
/** 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 });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof message !== 'string') return { ...params };
|
||||
return carried;
|
||||
}
|
||||
|
||||
const dataCoding = params.data_coding;
|
||||
const encoding = typeof dataCoding === 'number' ? encodingByDataCoding(dataCoding) : detect(message);
|
||||
const encoded = encodeMessage(message, encoding);
|
||||
/** 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;
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
data_coding: typeof dataCoding === 'number' ? dataCoding : consts.ENCODING[encoded.encoding],
|
||||
short_message: encoded.buffer,
|
||||
sm_length: encoded.buffer.length,
|
||||
};
|
||||
/** 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(
|
||||
@@ -139,42 +188,6 @@ function writeParams(
|
||||
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
|
||||
* 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 }> {
|
||||
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 };
|
||||
|
||||
const writtenTlvs = writeTlvs(tlvs);
|
||||
const writtenTlvs = writeTlvs(body.tlvs);
|
||||
|
||||
if (writtenTlvs.err) return { err: writtenTlvs.err };
|
||||
|
||||
|
||||
+2
-4
@@ -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, encodingNames, isEncodingName, unencodable } from './defs/encodings.ts';
|
||||
import { detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
|
||||
import { namedValue } from './error-from.ts';
|
||||
import { normaliseSmsId } from './sms-id.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
@@ -168,9 +168,7 @@ function refusedEncoding(encoding: unknown): 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 ${JSON.stringify(at.char)} (U+${point}) at index ${String(at.index)}; name UCS2 or leave encoding out`);
|
||||
return new Error(`encoding ${encoding} cannot carry ${unencodableText(at)}; name UCS2 or leave encoding out`);
|
||||
}
|
||||
|
||||
/** An alphabet the caller named has to carry the message; the one detect() picks always does. */
|
||||
|
||||
Reference in New Issue
Block a user