Read and write every text field on the wire as latin1 #16

Merged
lilleman merged 6 commits from cstring-latin1 into main 2026-09-21 08:28:44 +02:00
10 changed files with 273 additions and 37 deletions
+2
View File
@@ -292,6 +292,8 @@ the file.
- A string body is written in the alphabet its own `data_coding` names, and one that alphabet cannot - 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`. carry is refused by the codec — `message_payload` on the same terms as `short_message`.
- A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM. - A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.
- Every text field on the wire is latin1, and what the field cannot carry is refused rather than
truncated.
### [The session's life](docs/decisions.md#the-sessions-life) ### [The session's life](docs/decisions.md#the-sessions-life)
+12
View File
@@ -6,6 +6,18 @@
one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff. one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff.
A connect previously waited the operating system out, around 130 s on Linux against a host that A connect previously waited the operating system out, around 130 s on Linux against a host that
drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait. drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait.
- Addresses, ids and every other text field on the wire are read and written as latin1. A
`source_addr` of `Kaffeé` previously reached the application as `Kaffei`, because the codec wrote
the octet and then masked bit 7 reading it back; `destination_addr`, `system_id`, `message_id`,
`service_type` and the C-Octet String TLVs were affected the same way. A character past `U+00FF`
in one of those fields is now refused, where it used to go out as its low octet.
**Check what you stored before you roll this out.** Values your application persisted under 0.5.0
were read with bit 7 masked, so an address or a `message_id` carrying an octet above `0x7F` is
spelled differently now: a stored id will not match the receipt it belongs to, and a stored address
will not match the sender it came from. Ids most SMSCs issue are digits or hex and are unaffected.
- A `U+0000` inside a C-Octet String — `source_addr`, `message_id`, `system_id` and the rest — is
refused. An Octet String carries a NULL as before.
## 0.5.0 ## 0.5.0
+14 -5
View File
@@ -288,7 +288,11 @@ await session.sendSms({
``` ```
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address **Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
and 1 for a numeric one; the NPI fields default to 0. and 1 for a numeric one; the NPI fields default to 0. An address is latin1, so `é` is one octet on
the wire and an address you received always sends back. One outside `/^[\u0001-\u00FF]*$/` is
refused, naming the character and its index — strip or transliterate it first. An SMSC may still
refuse a non-ASCII sender of its own accord, which reaches you as a refusal such as
`ESME_RINVSRCADR`.
**Encoding.** **Encoding.**
@@ -342,9 +346,10 @@ you formatted. Refused before anything goes out: an invalid `Date`, `NaN`, `Infi
count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below. count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
Name a later instant as a `Date`, which goes out absolute. Name a later instant as a `Date`, which goes out absolute.
**What gets checked.** The library checks what it composes: an alphabet or a time you named, a string **What gets checked.** The library checks what it composes: an address you gave as `from` or `to`,
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you an alphabet or a time you named, a string body under a `data_coding` you named. What you formed
formatted, passes through as written. The same rule holds for `session.send()`. yourself, a `Buffer` body or a stamp you formatted, passes through as written. The same rule holds
for `session.send()`.
## Session ## Session
@@ -608,6 +613,8 @@ if (isCommand(pduObj, 'submit_sm')) {
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding` - A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
names, detected from the text where you name none. One that alphabet cannot carry is refused, names, detected from the text where you name none. One that alphabet cannot carry is refused,
naming the character, its code point and where it is. 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.
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user - A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
data headers, deliberately malformed bodies. data headers, deliberately malformed bodies.
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies. - `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
@@ -705,7 +712,9 @@ Who depends on this library, and what they may rely on.
spooling, scheduling, retry policy and billing belong to whatever this is the edge of, and state spooling, scheduling, retry policy and billing belong to whatever this is the edge of, and state
shared between instances goes through the store in goal 9. shared between instances goes through the store in goal 9.
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has - **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has
to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md);
what each later minor changes is in
[CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
Personas this README serves, in order: Personas this README serves, in order:
+21 -2
View File
@@ -443,8 +443,8 @@ rule and an index of the titles below.
the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string
`message_payload` outright and demanding `message_payload` outright and demanding
octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued 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 field against `data_coding`, which says nothing about them — a text field on the wire has an
own definition. alphabet of its own.
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.** - **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
@@ -478,6 +478,25 @@ rule and an index of the titles below.
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
change reads exactly as it did. change reads exactly as it did.
- **Every text field on the wire is latin1, and what the field cannot carry is refused rather than
truncated.** Maintainer's call, 2026-09-21, the refusals from the security and stability passes on
[#16](https://gitea.larvit.se/larvit/smpp-js/pulls/16). 3.4 calls these fields ASCII, so goal 3
settles the read alone — its generous clause is scoped to reading, and its sender clause is strict.
Goal 1 settles the write, being 3.4 as SMSCs actually run it: an operator routing an alphanumeric
sender through the upper half is traffic to keep, and Node's `ascii` write already put those octets
on the wire, so naming the write latin1 makes the round trip idempotent and no peer sees a change.
Goal 2 settles the refusals, both of them a `size()` that would have agreed with a `write()` that
put something else on the wire: a character past `U+00FF` written as its low octet, and a caller's
own `U+0000`, which a mandatory field's reader takes as the end of the field. Goal 4 settles them
twice over: for one character in every 256 that low octet is `0x00`, and the PDU went out malformed
on the operator's parser. `wantText()` and `wantCstringText()` are the only two places that decide
it. Rejected: reading latin1 and leaving the write spelled ASCII, which leaves two halves agreeing
only by accident. Rejected: refusing the upper half on send
to stay strict to 3.4's ASCII, which would be a new restriction taking away traffic this library
already sends and operators already accept, on no defect. Rejected: refusing `U+0000` in every
text field, which would buy one spelling by taking a legitimate octet away from the
length-prefixed Octet String, whose length octet is what ends it.
## 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,
+52 -20
View File
@@ -1,4 +1,5 @@
import type { Result, VoidResult } from '../result.ts'; import type { Result, VoidResult } from '../result.ts';
import { unencodableText } from './encodings.ts';
export type DestAddress = export type DestAddress =
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string } | { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
@@ -28,7 +29,7 @@ export type WireType<T extends ParamValue = ParamValue> = {
export function paramText(value: ParamValue | undefined): string { export function paramText(value: ParamValue | undefined): string {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value === 'number') return value.toString(); if (typeof value === 'number') return value.toString();
if (Buffer.isBuffer(value)) return value.toString('ascii'); if (Buffer.isBuffer(value)) return value.toString('latin1');
return ''; return '';
} }
@@ -79,11 +80,42 @@ function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult
return {}; return {};
} }
function wantText(value: ParamValue): Result<{ text: string }> { function pastLatin1(text: string): { err: Error } | undefined {
if (typeof value === 'string') return { text: value }; const index = text.search(/[\u0100-\uFFFF]/);
if (typeof value === 'number') return { text: value.toString() };
return { err: new Error(`Expected a string, got ${typeof value}`) }; if (index === -1) return undefined;
const char = String.fromCodePoint(text.codePointAt(index) ?? 0);
return {
err: new Error(
`latin1 cannot carry ${unencodableText({ char, index })}, and every text field on the wire is written in it; strip or transliterate it`,
),
};
}
function wantText(value: ParamValue): Result<{ text: string }> {
if (typeof value !== 'number' && typeof value !== 'string') {
return { err: new Error(`Expected a string or a number, got ${typeof value}`) };
}
const text = String(value);
return pastLatin1(text) ?? { text };
}
function wantCstringText(value: ParamValue): Result<{ text: string }> {
const { err, text } = wantText(value);
if (err) return { err };
const index = text.indexOf('\u0000');
if (index === -1) return { text };
return {
err: new Error(`U+0000 at index ${String(index)} would end the C-Octet String there`),
};
} }
function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> { function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
@@ -91,7 +123,7 @@ function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
const { err, text } = wantText(value); const { err, text } = wantText(value);
return err ? { err } : { bytes: Buffer.from(text, 'ascii') }; return err ? { err } : { bytes: Buffer.from(text, 'latin1') };
} }
function isDestAddress(value: unknown): value is DestAddress { function isDestAddress(value: unknown): value is DestAddress {
@@ -167,7 +199,7 @@ function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number
} }
} }
return { bytesRead: length + 1, value: buffer.toString('ascii', offset, offset + length) }; return { bytesRead: length + 1, value: buffer.toString('latin1', offset, offset + length) };
} }
function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult { function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult {
@@ -175,7 +207,7 @@ function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult
if (err) return { err }; if (err) return { err };
buffer.write(text, offset, 'ascii'); buffer.write(text, offset, 'latin1');
buffer[offset + text.length] = 0; buffer[offset + text.length] = 0;
return {}; return {};
@@ -248,7 +280,7 @@ export const string: WireType<string> = {
if (err) return { err }; if (err) return { err };
return { bytesRead: length + 1, value: buffer.toString('ascii', offset + 1, offset + 1 + length) }; return { bytesRead: length + 1, value: buffer.toString('latin1', offset + 1, offset + 1 + length) };
}, },
size(value) { size(value) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
@@ -271,7 +303,7 @@ export const string: WireType<string> = {
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
buffer.writeUInt8(text.length, offset); buffer.writeUInt8(text.length, offset);
buffer.write(text, offset + 1, 'ascii'); buffer.write(text, offset + 1, 'latin1');
return {}; return {};
}, },
@@ -288,12 +320,12 @@ export const cstring: WireType<string> = {
default: '', default: '',
read: readCstring, read: readCstring,
size(value) { size(value) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : { size: text.length + 1 }; return err ? { err } : { size: text.length + 1 };
}, },
write(value, buffer, offset) { write(value, buffer, offset) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : writeCstring(text, buffer, offset); return err ? { err } : writeCstring(text, buffer, offset);
}, },
@@ -401,7 +433,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
if ('dl_name' in dest) { if ('dl_name' in dest) {
buf.writeUInt8(2, offset++); buf.writeUInt8(2, offset++);
const name = writeCstring(dest.dl_name, buf, offset); const name = cstring.write(dest.dl_name, buf, offset);
if (name.err) return { err: name.err }; if (name.err) return { err: name.err };
@@ -417,7 +449,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
if (npi.err) return { err: npi.err }; if (npi.err) return { err: npi.err };
const addr = writeCstring(dest.destination_addr, buf, offset); const addr = cstring.write(dest.destination_addr, buf, offset);
if (addr.err) return { err: addr.err }; if (addr.err) return { err: addr.err };
@@ -505,7 +537,7 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
if (npi.err) return { err: npi.err }; if (npi.err) return { err: npi.err };
const addr = writeCstring(sme.destination_addr, buf, offset); const addr = cstring.write(sme.destination_addr, buf, offset);
if (addr.err) return { err: addr.err }; if (addr.err) return { err: addr.err };
@@ -538,15 +570,15 @@ export const tlv = {
? offset + length ? offset + length
: terminator; : terminator;
return { bytesRead: length, value: buf.toString('ascii', offset, end) }; return { bytesRead: length, value: buf.toString('latin1', offset, end) };
}, },
size(value: ParamValue) { size(value: ParamValue) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : { size: text.length + 1 }; return err ? { err } : { size: text.length + 1 };
}, },
write(value: ParamValue, buf: Buffer, offset: number) { write(value: ParamValue, buf: Buffer, offset: number) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : writeCstring(text, buf, offset); return err ? { err } : writeCstring(text, buf, offset);
}, },
@@ -559,7 +591,7 @@ export const tlv = {
read(buf: Buffer, offset: number, length = 0) { read(buf: Buffer, offset: number, length = 0) {
const err = outOfRange(buf, offset, length); const err = outOfRange(buf, offset, length);
return err ? { err } : { bytesRead: length, value: buf.toString('ascii', offset, offset + length) }; return err ? { err } : { bytesRead: length, value: buf.toString('latin1', offset, offset + length) };
}, },
size(value: ParamValue) { size(value: ParamValue) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
@@ -575,7 +607,7 @@ export const tlv = {
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
buf.write(text, offset, 'ascii'); buf.write(text, offset, 'latin1');
return {}; return {};
}, },
+16 -1
View File
@@ -7,10 +7,10 @@ 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 { cstring, paramText } from './defs/types.ts';
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts'; import { dataCodingByEncoding, 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 { maxSegments, smppTime, splitMessage } from './message.ts'; import { maxSegments, smppTime, splitMessage } from './message.ts';
export type SendSmsOptions = { export type SendSmsOptions = {
@@ -211,8 +211,23 @@ function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined {
return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash'); return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
} }
/** Asked of the wire type itself, so the codec cannot refuse an address this let through. */
function checkAddresses(sms: SendSmsInput): Error | undefined {
for (const option of ['from', 'to'] as const) {
const { err } = cstring.size(sms[option]);
if (err) return new Error(`${option}: ${err.message}`);
}
return undefined;
}
/** Every option a send can be refused for, so nothing is built for a message that will not go. */ /** Every option a send can be refused for, so nothing is built for a message that will not go. */
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> { function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
const unwritable = checkAddresses(sms);
if (unwritable) return { err: unwritable };
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true); const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
if (mode.err) return { err: mode.err }; if (mode.err) return { err: mode.err };
+27
View File
@@ -127,6 +127,33 @@ describe('parsing real PDUs', () => {
assert.equal(pduObj.params.short_message, 'hej 一'); assert.equal(pduObj.params.short_message, 'hej 一');
assert.equal(pduObj.tlvs.message_state?.tagValue, 2); assert.equal(pduObj.tlvs.message_state?.tagValue, 2);
}); });
test('keeps an alphanumeric sender whole through the wire and back', () => {
const pdu = encode({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
short_message: 'hej',
source_addr: 'Kaffeé',
source_addr_ton: 5,
},
seqNr: 9,
});
assert.ok(pdu.includes(Buffer.from('Kaffeé', 'latin1')));
assert.equal(decode(pdu).params.source_addr, 'Kaffeé');
});
test('refuses an address the field cannot carry rather than truncating it', () => {
const smuggled = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: '46709771337', source_addr: '46701113311\u0000EVIL' },
});
assert.ok(smuggled.err instanceof Error);
assert.equal(smuggled.buffer, undefined);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: '一' } }).err instanceof Error);
});
}); });
describe('encoding submit_sm', () => { describe('encoding submit_sm', () => {
+91 -1
View File
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts'; import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts';
import { paramText, types } from '../src/defs/types.ts';
import { tlvs } from '../src/defs/tlvs.ts'; import { tlvs } from '../src/defs/tlvs.ts';
import { types } from '../src/defs/types.ts';
describe('integers', () => { describe('integers', () => {
test('int8 reads, sizes and writes one octet', () => { test('int8 reads, sizes and writes one octet', () => {
@@ -69,6 +69,27 @@ describe('string (Octet String)', () => {
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
}); });
test('carries every latin1 octet, and refuses a character past it', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.string.read(Buffer.from([3, 0xE9, 0x80, 0xFF]), 0), {
bytesRead: 4,
value: 'é\u0080ÿ',
});
assert.deepEqual(types.string.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([3, 0xE9, 0x80, 0xFF]));
assert.ok(types.string.size('一').err instanceof Error);
assert.ok(types.string.write('一', Buffer.alloc(4), 0).err instanceof Error);
});
test('carries a NULL octet, which its length octet already bounds', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.string.write('a\u0000b', target, 0), {});
assert.deepEqual(target, Buffer.from([3, 0x61, 0x00, 0x62]));
});
}); });
describe('cstring (C-Octet String)', () => { describe('cstring (C-Octet String)', () => {
@@ -100,6 +121,23 @@ describe('cstring (C-Octet String)', () => {
assert.deepEqual(types.cstring.size(123), { size: 4 }); assert.deepEqual(types.cstring.size(123), { size: 4 });
}); });
test('carries every latin1 octet, and refuses a character past it', () => {
const address = Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9, 0x00]);
const target = Buffer.alloc(7);
assert.deepEqual(types.cstring.read(address, 0), { bytesRead: 7, value: 'Kaffeé' });
assert.deepEqual(types.cstring.write('Kaffeé', target, 0), {});
assert.deepEqual(target, address);
assert.ok(types.cstring.size('一').err instanceof Error);
assert.ok(types.cstring.write('一', Buffer.alloc(4), 0).err instanceof Error);
});
test('refuses a NULL of its own rather than ending the field early', () => {
assert.ok(types.cstring.size('46701113311\u0000EVIL').err instanceof Error);
assert.ok(types.cstring.write('46701113311\u0000EVIL', Buffer.alloc(17), 0).err instanceof Error);
});
test('refuses a string with no terminator rather than running off the end', () => { test('refuses a string with no terminator rather than running off the end', () => {
assert.ok(types.cstring.read(Buffer.from('abcd'), 0).err instanceof Error); assert.ok(types.cstring.read(Buffer.from('abcd'), 0).err instanceof Error);
}); });
@@ -142,6 +180,33 @@ describe('integer TLVs', () => {
}); });
}); });
describe('text TLVs', () => {
const encoded = Buffer.from([0xE9, 0x80, 0xFF]);
test('carry every latin1 octet, and refuse a character past it', () => {
const target = Buffer.alloc(3);
assert.deepEqual(types.tlv.string.read(encoded, 0, 3), { bytesRead: 3, value: 'é\u0080ÿ' });
assert.deepEqual(types.tlv.string.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, encoded);
assert.ok(types.tlv.string.size('一').err instanceof Error);
assert.ok(types.tlv.string.write('一', Buffer.alloc(3), 0).err instanceof Error);
});
test('carry them through a cstring tag too, terminator or none', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.tlv.cstring.read(encoded, 0, 3), { bytesRead: 3, value: 'é\u0080ÿ' });
assert.deepEqual(types.tlv.cstring.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([0xE9, 0x80, 0xFF, 0x00]));
assert.ok(types.tlv.cstring.size('一').err instanceof Error);
assert.ok(types.tlv.cstring.write('一', Buffer.alloc(4), 0).err instanceof Error);
assert.ok(types.tlv.cstring.write('a\u0000b', Buffer.alloc(4), 0).err instanceof Error);
});
});
describe('buffer', () => { describe('buffer', () => {
const expected = Buffer.from('abcd1234'); const expected = Buffer.from('abcd1234');
@@ -170,6 +235,20 @@ describe('buffer', () => {
assert.deepEqual(target, expected); assert.deepEqual(target, expected);
}); });
test('takes a string as the latin1 octets it stands for', () => {
const target = Buffer.alloc(3);
assert.deepEqual(types.buffer.size('é\u0080ÿ'), { size: 3 });
assert.deepEqual(types.buffer.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([0xE9, 0x80, 0xFF]));
});
});
describe('paramText()', () => {
test('renders a Buffer parameter as the latin1 text its octets spell', () => {
assert.equal(paramText(Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9])), 'Kaffeé');
});
}); });
describe('dest_address_array', () => { describe('dest_address_array', () => {
@@ -206,6 +285,11 @@ describe('dest_address_array', () => {
types.dest_address_array.write(expected, target, 0); types.dest_address_array.write(expected, target, 0);
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
const smuggled: DestAddress[] = [{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46\u0000EVIL' }];
assert.ok(types.dest_address_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
assert.ok(types.dest_address_array.write([{ dl_name: '一' }], Buffer.alloc(16), 0).err instanceof Error);
}); });
test('refuses a field value the wire cannot hold instead of throwing', () => { test('refuses a field value the wire cannot hold instead of throwing', () => {
@@ -245,6 +329,12 @@ describe('unsuccess_sme_array', () => {
types.unsuccess_sme_array.write(expected, target, 0); types.unsuccess_sme_array.write(expected, target, 0);
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
const smuggled: UnsuccessSme[] = [
{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: 'a\u0000b', error_status_code: 0 },
];
assert.ok(types.unsuccess_sme_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
}); });
test('refuses a field value the wire cannot hold instead of throwing', () => { test('refuses a field value the wire cannot hold instead of throwing', () => {
+25
View File
@@ -345,6 +345,31 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
}); });
}); });
describe('an address the field cannot carry', () => {
test('refuses it through sendSms(), with nothing reaching the socket', async t => {
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const refusals: ['from' | 'to', string, RegExp][] = [
['from', '46701113311\u0000EVIL', /U\+0000 at index 11/],
['from', 'Kaffe一', /"一" \(U\+4E00\) at index 5/],
['from', '😀', /"😀" \(U\+1F600\) at index 0/],
['to', 'Kaffe一', /"一" \(U\+4E00\) at index 5/],
];
for (const [option, address, names] of refusals) {
const sent = await session.sendSms({ from, message: 'Hello world', to, [option]: address });
assert.ok(sent.err instanceof Error, address);
assert.match(sent.err.message, new RegExp(`^${option}: `), 'names the option the caller wrote');
assert.match(sent.err.message, names);
assert.deepEqual(sent.smsIds, [], address);
}
assert.deepEqual(smsc.octets, []);
});
});
describe('a time no peer can read', () => { describe('a time no peer can read', () => {
const invalid = new Date('nope'); const invalid = new Date('nope');
+13 -8
View File
@@ -189,14 +189,11 @@ and is also what the panel ranked hardest — two methods, one answer.
### Correctness, ahead of everything below ### Correctness, ahead of everything below
- [ ] **Read C-Octet Strings as `latin1`, so an address survives the wire.** `defs/types.ts` reads - [ ] **Refuse a non-finite number where a text field coerces one.** `wantText()` in `defs/types.ts`
with `toString('ascii')` at four sites and writes with `write(text, 'ascii')` at three. Node stringifies a number so `message_id: 123` writes `"123"`, which is deliberate and tested. It
masks bit 7 when decoding and not when encoding, so the codec writes `0xE9` and reads back takes `NaN` and `Infinity` on the same path, so `sendSms({ from: NaN })` puts the literal sender
`0x69`: an inbound `source_addr` of `Kaffeé` reaches the application as `Kaffei`, with no raw `NaN` on the wire and reports success — goal 2. Gate the numeric branch on `Number.isFinite`.
escape hatch as `short_message` has in `shortMessageOctets`. Affects `source_addr`, Predates the latin1 guard; found by the stability review of #16.
`destination_addr`, `system_id`, `message_id`, `service_type` and the cstring TLVs, and makes
`objToPdu(pduToObj(x))` non-idempotent for them. Goals 1 and 3. Verified in the container:
`Buffer.from([0xE9]).toString('ascii')` is `'i'`.
- [ ] **Answer `alert_notification` and `outbind` by not answering them.** Both are response-less in - [ ] **Answer `alert_notification` and `outbind` by not answering them.** Both are response-less in
SMPP 3.4, both fall through `route()`'s default into `unhandled()`, which calls SMPP 3.4, both fall through `route()`'s default into `unhandled()`, which calls
@@ -268,6 +265,14 @@ and is also what the panel ranked hardest — two methods, one answer.
budget into `ExpiringGroups` as a weighed capacity, and have `trim()` report whether the budget into `ExpiringGroups` as a weighed capacity, and have `trim()` report whether the
current group survived. Named by 6 of 9 readers. current group survived. Named by 6 of 9 readers.
- [ ] **Let the two address arrays size a C-Octet String through `cstring.size()`.**
`sizeDestAddresses()` and `sizeUnsuccessSmes()` spell "len + 1" themselves, and each `offset +=`
after a write spells it a third time, so `dest_address_array` and `unsuccess_sme_array` each
know the cost in three places. Route both through `cstring.size()` and advance the offset by
what it returns. That also makes `size()` refuse where `write()` already does, so the error
arrives from the first call rather than the second; today the pair only fails closed because
`writeParams()` and `writeTlvs()` both bail on the write. From the stability review of #16.
- [ ] **Split the two questions `OutgoingRequests.linkDown()` answers.** `Session.drain()` calls it - [ ] **Split the two questions `OutgoingRequests.linkDown()` answers.** `Session.drain()` calls it
twice for opposite conclusions — "nothing to drain, success" and "the link died under us, twice for opposite conclusions — "nothing to drain, success" and "the link died under us,
failure" — and `outgoing-requests.ts` reads it a third way. Two named predicates. Named by 7 failure" — and `outgoing-requests.ts` reads it a third way. Two named predicates. Named by 7