From 75d47945227699d3d31055a8b11f006ae45c3214 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 27 Sep 2026 15:12:09 +0200 Subject: [PATCH] Type each known TLV's value by its tag, on read and on write --- CHANGELOG.md | 5 ++ README.md | 5 ++ src/defs/tlvs.ts | 94 ++++++++++++++++++++++++++++--------- src/index.ts | 4 +- src/pdu.ts | 12 ++--- src/retained-pdu.ts | 6 +-- src/server.ts | 4 +- src/session.ts | 4 +- src/sms.ts | 4 +- test/pdu.test.ts | 36 +++++++++++--- test/session-extras.test.ts | 2 +- todo.md | 8 ---- 12 files changed, 131 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0dbda..f286cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,11 @@ `Buffer.isBuffer()` or `typeof` check written for 0.5.0 now reads it as absent. Read `tagValue[0]` for the first occurrence. `objToPdu()`, `session.send()` and `session.sendReturn()` take `{ tagValue: [value] }` for them and refuse a lone value before anything goes out. +- `pduObj.tlvs` and every `tlvs` input are typed per tag, as `Tlvs` and `TlvInputs`: + `receipted_message_id` reads as a `string`, `callback_num` as a `Buffer[]`, and a value of the wrong + type for its tag fails to compile. An unknown tag reads as a `Buffer` under its decimal id. Code + that narrowed `tagValue` with `typeof` still compiles; a `Record` annotation does not, + so use `Tlvs`. - `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it. ## 0.5.0 diff --git a/README.md b/README.md index aee9db5..12673d4 100644 --- a/README.md +++ b/README.md @@ -613,6 +613,9 @@ if (isCommand(pduObj, 'submit_sm')) { and hands back the UDH where the PDU carries one. - `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that carried them, `'udh'` or `'sar'`, or `undefined` for a whole message. +- `pduObj.tlvs` is typed per tag, as `Tlvs`: `receipted_message_id` a string, `message_state` a + number, `message_payload` a `Buffer`. A tag the table does not define is a `Buffer` keyed by its + decimal id, `tlvs['5142']`. - `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and `broadcast_error_status` may repeat in one PDU, so each reads as an array of every occurrence in wire order: `number[]` for `callback_num_pres_ind` and `broadcast_error_status`, `Buffer[]` for the rest. @@ -627,6 +630,8 @@ if (isCommand(pduObj, 'submit_sm')) { naming the character, its code point and where it is. - Every text field is latin1: addresses, `system_id`, `message_id`, `service_type` and the C-Octet String TLVs. A character past `U+00FF` is refused, as is a `U+0000` in a C-Octet String. +- `tlvs` is typed per tag, as `TlvInputs`, so `{ message_state: { tagValue: 'ENROUTE' } }` fails to + compile. A tag the table does not define takes a `tagId`. - The five repeatable TLVs take an array, written as one TLV per element; a lone value or an empty array is refused. - A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user diff --git a/src/defs/tlvs.ts b/src/defs/tlvs.ts index 7959625..da2472f 100644 --- a/src/defs/tlvs.ts +++ b/src/defs/tlvs.ts @@ -79,15 +79,18 @@ const specs = tlvSpecs({ its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer }, }); -export type TlvName = keyof typeof specs; +type Specs = typeof specs; -export const tlvs: Record & Record = { - ...specs, - // Alternate spellings; the definition behind each keeps its canonical name. +export type TlvName = keyof Specs; + +// Alternate spellings; the definition behind each keeps its canonical name. +const alternates = { alert_on_msg_delivery: specs.alert_on_message_delivery, failed_broadcast_area_identifier: specs.broadcast_area_identifier, }; +export const tlvs: Record & Record = { ...specs, ...alternates }; + export const tlvsById: Record = {}; for (const definition of Object.values(specs)) { @@ -97,11 +100,28 @@ for (const definition of Object.values(specs)) { /** Fallback for tags this table does not know: keep the raw octets. */ export const tlvDefault: WireType = tlv.buffer; -export type Tlv = { - tagId: number; - tagName: string | undefined; - tagValue: TlvValue; -}; +type Repeated = Specs[K] extends { multiple: true } ? V[] : V; + +type WireValue = Specs[K]['type']['default']; + +export type TlvReadValue = Repeated>; + +/** A lone text field also takes a number, and a lone octet field text, which goes out as latin1. */ +export type TlvWriteValue = Specs[K] extends { multiple: true } ? TlvReadValue + : WireValue extends number ? number + : WireValue extends string ? number | string + : Buffer | number | string; + +type OneTlv = { tagId: number; tagName: K; tagValue: TlvReadValue }; + +export type Tlv = { [N in K]: OneTlv }[K]; + +/** A tag the TLV table does not define, keyed by its decimal id. */ +export type UnknownTlv = { tagId: number; tagName: undefined; tagValue: Buffer }; + +type KnownTlvs = { [K in TlvName]?: OneTlv }; + +export type Tlvs = KnownTlvs & Record<`${number}`, UnknownTlv>; export type TlvInput = { /** Resolved from the record key; pass it for a tag the TLV table does not define. */ @@ -109,6 +129,14 @@ export type TlvInput = { tagValue: TlvValue; }; +type Alternate = keyof typeof alternates; + +type Canonical = K extends Alternate ? (typeof alternates)[K]['tag'] : K; + +export type TlvInputs = { + [K in Alternate | TlvName]?: { tagId?: number | undefined; tagValue: TlvWriteValue> }; +} & Record; + export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> { const tagId = input.tagId ?? tlvs[name]?.id; @@ -198,10 +226,38 @@ function readTlv(pdu: Buffer, offset: number): Result<{ octets: number; occurren return { occurrence: { definition, tagId, value: read.value }, octets: 4 + tagLength }; } +function isTlvName(name: string): name is TlvName { + return Object.hasOwn(specs, name); +} + +function readsAs(definition: TlvDefinition, value: unknown): boolean { + const kind = definition.type.default; + const fits = (one: unknown): boolean => typeof one === typeof kind && Buffer.isBuffer(one) === Buffer.isBuffer(kind); + + return definition.multiple === true ? Array.isArray(value) && value.every(fits) : fits(value); +} + +function isTlvShape(tlv: unknown): tlv is { tagId: number; tagName: unknown; tagValue: unknown } { + return typeof tlv === 'object' && tlv !== null && 'tagId' in tlv && typeof tlv.tagId === 'number' + && 'tagName' in tlv && 'tagValue' in tlv; +} + +function isTlv(key: string, tlv: unknown): boolean { + if (!isTlvShape(tlv)) return false; + if (tlv.tagName === undefined) return /^\d+$/.test(key) && Buffer.isBuffer(tlv.tagValue); + + return tlv.tagName === key && isTlvName(key) && readsAs(specs[key], tlv.tagValue); +} + +/** Every entry keyed by its tag name, or an unknown tag by its decimal id, holding what its table type reads. */ +export function isTlvs(record: Record): record is Tlvs { + return Object.entries(record).every(([key, tlv]) => isTlv(key, tlv)); +} + /** Keyed by tag name, a repeatable tag listing every occurrence in wire order and any other keeping its last. */ -function keyedTlvs(occurrences: Occurrence[]): Record { +function keyedTlvs(occurrences: Occurrence[]): Result<{ tlvs: Tlvs }> { const repeated = new Map(); - const tlvs: Record = {}; + const tlvs: Record = {}; for (const { definition, tagId, value } of occurrences) { const key = definition?.tag ?? tagId.toString(); @@ -217,19 +273,13 @@ function keyedTlvs(occurrences: Occurrence[]): Record { } for (const [key, { tagId, values }] of repeated) { - const buffers = values.filter(value => Buffer.isBuffer(value)); - - tlvs[key] = { - tagId, - tagName: key, - tagValue: buffers.length === values.length ? buffers : values.filter(value => typeof value === 'number'), - }; + tlvs[key] = { tagId, tagName: key, tagValue: values }; } - return tlvs; + return isTlvs(tlvs) ? { tlvs } : { err: new Error('A TLV did not read as its table type') }; } -export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Record }> { +export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Tlvs }> { const occurrences: Occurrence[] = []; let offset = start; @@ -242,5 +292,7 @@ export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; offset += read.octets; } - return { offset, tlvs: keyedTlvs(occurrences) }; + const keyed = keyedTlvs(occurrences); + + return keyed.err ? { err: keyed.err } : { offset, tlvs: keyed.tlvs }; } diff --git a/src/index.ts b/src/index.ts index d24f9d0..96cf427 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,10 +64,10 @@ export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts' export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts'; export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts'; export type { ErrorName } from './defs/errors.ts'; -export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; +export type { PduObject, PduObjectInput, TlvInput, TlvInputs } from './pdu.ts'; export type { PduHeader } from './pdu-refusal.ts'; export type { SplitOptions } from './message.ts'; -export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts'; +export type { Tlv, TlvDefinition, TlvName, TlvReadValue, TlvWriteValue, Tlvs, UnknownTlv } from './defs/tlvs.ts'; export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts'; /** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */ diff --git a/src/pdu.ts b/src/pdu.ts index 8ba806f..be649db 100644 --- a/src/pdu.ts +++ b/src/pdu.ts @@ -3,7 +3,7 @@ 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, TlvInput } from './defs/tlvs.ts'; +import type { TlvInput, TlvInputs, Tlvs } from './defs/tlvs.ts'; import { PduRefusedError, framingRefusal } from './pdu-refusal.ts'; import { cmds, commandNameById, respNameFor } from './defs/commands.ts'; import { hasUdh } from './defs/constants.ts'; @@ -23,7 +23,7 @@ export type PduObjectInput = { cmdStatus?: ErrorName; params?: PduParamsInput; seqNr?: number; - tlvs?: Record | undefined; + tlvs?: TlvInputs | undefined; }; /** @@ -43,10 +43,10 @@ export type PduObject = { * the peer put in `message_payload` is not here; `messageOctets()` is what reads either. */ shortMessageOctets: Buffer | undefined; - tlvs: Record; + tlvs: Tlvs; }; -export type { TlvInput }; +export type { TlvInput, TlvInputs }; const respBit = 0x80000000; @@ -293,7 +293,7 @@ function readOptionalParams( pdu: Buffer, start: number, afterShortMessage: boolean, -): Result<{ tlvs: Record }> { +): Result<{ tlvs: Tlvs }> { const plain = parseTlvs(pdu, start); if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs }; @@ -418,7 +418,7 @@ export function pduReturn( pdu: Buffer | PduObject, status: ErrorName = 'ESME_ROK', params: Record = {}, - tlvs?: Record, + tlvs?: TlvInputs, ): Result<{ buffer: Buffer }> { if (Buffer.isBuffer(pdu)) { const parsed = pduToObj(pdu); diff --git a/src/retained-pdu.ts b/src/retained-pdu.ts index 641731a..e42647d 100644 --- a/src/retained-pdu.ts +++ b/src/retained-pdu.ts @@ -1,12 +1,12 @@ import type { ParamValue } from './defs/types.ts'; import type { PduObject } from './pdu.ts'; -import type { Tlv } from './defs/tlvs.ts'; import { detachedTlv, tlvOctets } from './defs/types.ts'; +import { isTlvs } from './defs/tlvs.ts'; /** Wire reads hand back views, so retaining one PDU would pin the whole chunk it arrived in. */ export function detach(pduObj: PduObject): PduObject { const params: Record = {}; - const tlvs: Record = {}; + const tlvs: Record = {}; for (const [name, value] of Object.entries(pduObj.params)) { params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value; @@ -21,7 +21,7 @@ export function detach(pduObj: PduObject): PduObject { ? params.short_message : pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets); - return { ...pduObj, params, shortMessageOctets: octets, tlvs }; + return { ...pduObj, params, shortMessageOctets: octets, tlvs: isTlvs(tlvs) ? tlvs : pduObj.tlvs }; } // Measured heap beyond the octets, so a PDU of empty fields or empty TLVs is not free. diff --git a/src/server.ts b/src/server.ts index 7cad123..0728637 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,5 @@ import type { CloseOptions, OnRequest } from './session-options.ts'; -import type { PduObject, TlvInput } from './pdu.ts'; +import type { PduObject, TlvInputs } from './pdu.ts'; import type { Result, VoidResult } from './result.ts'; import type { Server as NetServer, Socket } from 'node:net'; import type { Server as TlsServer, TlsOptions } from 'node:tls'; @@ -162,7 +162,7 @@ async function authenticate( } /** An ESME reads a missing sc_interface_version as this SMSC having none. */ -function bindRespTlvs(session: Session, options: ServerOptions): Record | undefined { +function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | undefined { if (!session.acceptsOptionalParams()) return undefined; return { diff --git a/src/session.ts b/src/session.ts index 16b9775..71370c9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,7 +1,7 @@ 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, TlvInput } from './pdu.ts'; +import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; import type { PduRefusedError } from './pdu-refusal.ts'; import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; import type { Result, VoidResult } from './result.ts'; @@ -175,7 +175,7 @@ export class Session extends EventEmitter { pdu: PduObject, status: ErrorName = 'ESME_ROK', params: Record = {}, - tlvs?: Record, + tlvs?: TlvInputs, ): Promise { return Promise.resolve( this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr), diff --git a/src/sms.ts b/src/sms.ts index 4635421..dc57106 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -1,6 +1,6 @@ import type { ErrorName } from './defs/errors.ts'; import type { MessageState } from './defs/constants.ts'; -import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; +import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; import type { Result, VoidResult } from './result.ts'; import type { Session } from './session.ts'; import { UnansweredError } from './unanswered-error.ts'; @@ -179,7 +179,7 @@ function receiptText(sms: Sms, smsId: string, status: MessageState): string { ].join(' '); } -function receiptTlvs(smsId: string, status: MessageState): Record { +function receiptTlvs(smsId: string, status: MessageState): TlvInputs { return { message_state: { tagValue: consts.MESSAGE_STATE[status] }, receipted_message_id: { tagValue: smsId }, diff --git a/test/pdu.test.ts b/test/pdu.test.ts index c2de212..1d9b433 100644 --- a/test/pdu.test.ts +++ b/test/pdu.test.ts @@ -475,6 +475,10 @@ describe('TLVs', () => { }, })); + const firstRead: Buffer | undefined = pduObj.tlvs.callback_num?.tagValue[0]; + const presentation: number | undefined = pduObj.tlvs.callback_num_pres_ind?.tagValue[0]; + + assert.deepEqual([firstRead, presentation], [first, 1]); assert.deepEqual(pduObj.tlvs.callback_num?.tagValue, [first, second]); assert.deepEqual(pduObj.tlvs.callback_num_pres_ind?.tagValue, [1]); }); @@ -493,18 +497,38 @@ describe('TLVs', () => { test('refuses a repeatable TLV given one value, and a lone TLV given several', () => { const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' }; - for (const tlvs of [ - { callback_num: { tagValue: Buffer.from('01', 'hex') } }, - { callback_num: { tagValue: [] } }, - { source_port: { tagValue: [1234, 1235] } }, + for (const { buffer, err } of [ + // @ts-expect-error callback_num repeats, so it takes an array + objToPdu({ cmdName: 'submit_sm', params, tlvs: { callback_num: { tagValue: Buffer.from('01', 'hex') } } }), + objToPdu({ cmdName: 'submit_sm', params, tlvs: { callback_num: { tagValue: [] } } }), + // @ts-expect-error source_port does not repeat + objToPdu({ cmdName: 'submit_sm', params, tlvs: { source_port: { tagValue: [1234, 1235] } } }), ]) { - const { buffer, err } = objToPdu({ cmdName: 'submit_sm', params, tlvs }); - assert.equal(buffer, undefined); assert.ok(err instanceof Error); } }); + test('types each known TLV by its tag, and an unknown one as octets', () => { + const pduObj = decode(encode({ + cmdName: 'deliver_sm', + params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' }, + tlvs: { + 5142: { tagId: 5142, tagValue: Buffer.from('01', 'hex') }, + message_state: { tagValue: 2 }, + receipted_message_id: { tagValue: '0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15' }, + }, + })); + const id: string | undefined = pduObj.tlvs.receipted_message_id?.tagValue; + const state: number | undefined = pduObj.tlvs.message_state?.tagValue; + const unknown: Buffer | undefined = pduObj.tlvs['5142']?.tagValue; + + assert.deepEqual([id, state, unknown], ['0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15', 2, Buffer.from('01', 'hex')]); + + // @ts-expect-error message_state is an integer + assert.ok(objToPdu({ cmdName: 'deliver_sm', params: {}, tlvs: { message_state: { tagValue: 'ENROUTE' } } }).err); + }); + test('round-trips a receipt with message_state and receipted_message_id', () => { const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx'; const pduObj = decode(encode({ diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index fa52f1a..43c29ae 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1808,7 +1808,7 @@ describe('where a segment says it is concatenated', () => { }); test('reads no concatenation where a sar_* TLV is missing', () => { - const lone = { + const lone: PduObject = { ...sarSegment(5, 1, 2), tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } }, }; diff --git a/todo.md b/todo.md index ee74247..2a11169 100644 --- a/todo.md +++ b/todo.md @@ -189,14 +189,6 @@ and is also what the panel ranked hardest — two methods, one answer. ### Correctness, ahead of everything below -- [ ] **Type each known TLV's value by its tag, on read and on write, before 0.6.0 is cut.** Every - `tagValue` is `TlvValue`, so `{ callback_num: { tagValue: buf } }` compiles and is refused - only at runtime, and reading `receipted_message_id` has to narrow out arrays it can never - hold. Derive the types from the specs' own wire types and `multiple` flag. It has to land in - the same minor as the arrays, or narrowing the types is a second break. A test compiles the - CHANGELOG's `tagValue[0]` advice, which the union does not type-check today. From the - product-owner review of #25. - - [ ] **Settle what a repeated tag not marked `multiple` reads as, and pin it in a test.** A vendor tag or a known single-value tag a peer sends twice keeps the last occurrence and drops the rest silently, which goal 3 argues against; listing it would change every such tag's shape.