Read and write every text field on the wire as latin1 #16
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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', () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user