Compare commits

3 Commits

Author SHA1 Message Date
lilleman bbfd1083b8 File what tracing the numeric branch turned up
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 36s
2026-09-21 10:29:43 +02:00
lilleman faa5482c7a Spell a refused NaN as NaN, where JSON spells it null 2026-09-21 10:29:43 +02:00
lilleman 93d794e61b Say what is refused, and cover the two wire types the loop missed 2026-09-21 10:29:42 +02:00
6 changed files with 49 additions and 10 deletions
+4 -4
View File
@@ -25,10 +25,10 @@
will not match the sender it came from. Ids most SMSCs issue are digits or hex and are unaffected. 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 - 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. refused. An Octet String carries a NULL as before.
- A number with no decimal spelling — `NaN`, `Infinity`, `-Infinity` — is refused where a text field - A non-finite number — `NaN`, `Infinity`, `-Infinity` — is refused where a text field takes one.
takes one. `sendSms({ from: NaN })` put the literal sender `NaN` on the wire and reported the send `sendSms({ from: NaN })` put the literal sender `NaN` on the wire and reported the send as
as successful; `message_id`, `source_addr` and every other text field took such a number the same successful; `message_id`, `source_addr` and every other text field took such a number the same way.
way. A finite number still writes its decimals, so `message_id: 123` is unchanged. Any other number goes out as `String()` spells it, so `message_id: 123` is unchanged.
## 0.5.0 ## 0.5.0
+6 -1
View File
@@ -38,6 +38,11 @@ export function paramNumber(value: ParamValue | undefined, fallback: number): nu
return typeof value === 'number' ? value : fallback; return typeof value === 'number' ? value : fallback;
} }
/** Spells a refused value for the caller who wrote it; JSON spells NaN and the infinities `null`. */
export function valueText(value: unknown): string {
return typeof value === 'number' ? String(value) : JSON.stringify(value);
}
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined { function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
if (offset < 0 || needed < 0 || offset + needed > buffer.length) { if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
return new Error( return new Error(
@@ -50,7 +55,7 @@ function outOfRange(buffer: Buffer, offset: number, needed: number): Error | und
function wantInt(value: ParamValue, max: number): Result<{ int: number }> { function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
if (typeof value !== 'number' || !Number.isInteger(value)) { if (typeof value !== 'number' || !Number.isInteger(value)) {
return { err: new Error(`Expected an integer, got ${JSON.stringify(value)}`) }; return { err: new Error(`Expected an integer, got ${valueText(value)}`) };
} }
if (value < 0 || value > max) { if (value < 0 || value > max) {
+2 -2
View File
@@ -9,7 +9,7 @@ import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { hasUdh } from './defs/constants.ts'; import { hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeBody } from './message.ts'; import { decodeMessage, encodeBody } from './message.ts';
import { errorNameById, errors, isErrorName } from './defs/errors.ts'; import { errorNameById, errors, isErrorName } from './defs/errors.ts';
import { paramNumber } from './defs/types.ts'; import { paramNumber, valueText } from './defs/types.ts';
import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } 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. */ /** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
@@ -234,7 +234,7 @@ function buildPdu(
} }
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) { if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) {
return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) }; return { err: new Error(`Invalid seqNr: ${valueText(seqNr)}`) };
} }
const built = buildBody(definition, cmdName, cmdStatus, params, tlvs); const built = buildBody(definition, cmdName, cmdStatus, params, tlvs);
+6 -2
View File
@@ -51,6 +51,11 @@ describe('header', () => {
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0x80000001 })).seqNr, 0x80000001); assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0x80000001 })).seqNr, 0x80000001);
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF); assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error); assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error);
const notANumber = objToPdu({ cmdName: 'submit_sm', seqNr: NaN });
assert.ok(notANumber.err instanceof Error);
assert.match(notANumber.err.message, /NaN/);
}); });
}); });
@@ -144,7 +149,7 @@ describe('parsing real PDUs', () => {
assert.equal(decode(pdu).params.source_addr, 'Kaffeé'); assert.equal(decode(pdu).params.source_addr, 'Kaffeé');
}); });
test('refuses an address the field cannot carry rather than truncating it', () => { test('refuses an address the field cannot carry rather than truncating or coercing it', () => {
const smuggled = objToPdu({ const smuggled = objToPdu({
cmdName: 'submit_sm', cmdName: 'submit_sm',
params: { destination_addr: '46709771337', source_addr: '46701113311\u0000EVIL' }, params: { destination_addr: '46709771337', source_addr: '46701113311\u0000EVIL' },
@@ -154,7 +159,6 @@ describe('parsing real PDUs', () => {
assert.equal(smuggled.buffer, undefined); assert.equal(smuggled.buffer, undefined);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: '一' } }).err instanceof Error); assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: '一' } }).err instanceof Error);
// Coercing these spells the senders "NaN" and "Infinity", which a peer reads as those letters.
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: NaN } }).err instanceof Error); assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: NaN } }).err instanceof Error);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: Infinity } }).err instanceof Error); assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: Infinity } }).err instanceof Error);
}); });
+9 -1
View File
@@ -39,6 +39,12 @@ describe('integers', () => {
assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error); assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error);
assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error); assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error);
assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error); assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error);
const notANumber = types.int8.write(NaN, Buffer.alloc(1), 0);
// JSON spells NaN and the infinities `null`, which is a value the caller never wrote.
assert.ok(notANumber.err instanceof Error);
assert.match(notANumber.err.message, /NaN/);
}); });
}); });
@@ -112,7 +118,7 @@ describe('cstring (C-Octet String)', () => {
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
}); });
test('coerces a numeric value to its decimal string, and refuses one with no decimals', () => { test('coerces a numeric value to its decimal string, and refuses a non-finite one', () => {
const target = Buffer.alloc(4); const target = Buffer.alloc(4);
types.cstring.write(123, target, 0); types.cstring.write(123, target, 0);
@@ -124,7 +130,9 @@ describe('cstring (C-Octet String)', () => {
assert.ok(types.cstring.size(value).err instanceof Error, String(value)); assert.ok(types.cstring.size(value).err instanceof Error, String(value));
assert.ok(types.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value)); assert.ok(types.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value)); assert.ok(types.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.buffer.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.tlv.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value)); assert.ok(types.tlv.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.tlv.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
} }
}); });
+22
View File
@@ -216,6 +216,28 @@ and is also what the panel ranked hardest — two methods, one answer.
send — and not this one. If goal 2 forbids reporting on a message we cannot fully account for, send — and not this one. If goal 2 forbids reporting on a message we cannot fully account for,
that is the answer; it is stated in no file today either way. that is the answer; it is stated in no file today either way.
- [ ] **Return an `err` where `message` is not a string, rather than throwing.**
`sendSms({ message: undefined })` — a forgotten property — reaches `value.replace()` in
`defs/encodings.ts` through the alphabet detection `checkOptions()` runs, and the `TypeError`
escapes `submitSms()` into the caller's process; `NaN` and `12345` do the same. README promises
"Never throws. Every fallible call resolves to `{ err?, … }`" and AGENTS.md hard rule 1 says it
again, so the docs are false for the likeliest caller mistake there is. From the stability
review of #18.
- [ ] **Derive `sm_length` for a numeric body, or refuse one.** `resolveBody()` in `pdu.ts` reads the
length only where the body is a Buffer or a string, so
`objToPdu({ cmdName: 'submit_sm', params: { short_message: 12345 } })` writes `sm_length: 0`,
then five octets after it, and reports success — and this library's own parser refuses what it
built, as "TLV 12594 runs past the end of the PDU". Goals 1 and 2. From the stability review
of #18.
- [ ] **Settle which numbers may spell a text field, and refuse the rest.** `wantText()` takes every
finite number through `String()`, so `message_id: 1e21` writes `1e+21` and `from: 0.1 + 0.2`
writes `0.30000000000000004` — neither is the id or the address the caller meant, and both are
reported as sent. The numeric branch exists for a digit sequence (`message_id: 123`): either
narrow it to one, or record why exponential notation may go on the wire. Goal 3, and the open
half of the non-finite guard #18 shipped. From the stability review of #18.
### Throughput — goal 6, and the default window is where we are slowest ### Throughput — goal 6, and the default window is where we are slowest
- [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink, - [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink,