Skip to content

Commit 124c362

Browse files
committed
fix(core/cbor): handle exponent notation when serializing NumericValue containers
1 parent f8e5c6b commit 124c362

7 files changed

Lines changed: 225 additions & 37 deletions

File tree

.changeset/fuzzy-dragons-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@smithy/core": patch
3+
---
4+
5+
fix: handle exponent notation when serializing NumericValue wrappers

packages/core/src/submodules/cbor/cbor-decode.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -377,25 +377,41 @@ function decodeTagValue(
377377
return minor === 3 ? -b - BigInt(1) : b;
378378
} else if (minor === 4) {
379379
const decimalFraction = decode(at + offset, to);
380-
const [exponent, mantissa] = decimalFraction;
380+
const [rawExponent, mantissa] = decimalFraction;
381381
const normalizer = mantissa < 0 ? -1 : 1;
382-
const mantissaStr = "0".repeat(Math.abs(exponent) + 1) + String(BigInt(normalizer) * BigInt(mantissa));
382+
const absMantissa = BigInt(normalizer) * BigInt(mantissa);
383+
const mantissaDigits = String(absMantissa);
384+
const sign = mantissa < 0 ? "-" : "";
383385

384386
let numericString: string;
385-
const sign = mantissa < 0 ? "-" : "";
386387

387-
numericString =
388-
exponent === 0
389-
? mantissaStr
390-
: mantissaStr.slice(0, mantissaStr.length + exponent) + "." + mantissaStr.slice(exponent);
391-
numericString = numericString.replace(/^0+/g, "");
392-
if (numericString === "") {
393-
numericString = "0";
394-
}
395-
if (numericString[0] === ".") {
396-
numericString = "0" + numericString;
388+
const isSmallExponent = typeof rawExponent === "number" && Math.abs(rawExponent) <= 1e15;
389+
if (isSmallExponent) {
390+
const exponent = rawExponent as number;
391+
const mantissaStr = "0".repeat(Math.abs(exponent) + 1) + mantissaDigits;
392+
393+
numericString =
394+
exponent === 0
395+
? mantissaStr
396+
: mantissaStr.slice(0, mantissaStr.length + exponent) + "." + mantissaStr.slice(exponent);
397+
numericString = numericString.replace(/^0+/g, "");
398+
if (numericString === "") {
399+
numericString = "0";
400+
}
401+
if (numericString[0] === ".") {
402+
numericString = "0" + numericString;
403+
}
404+
numericString = sign + numericString;
405+
} else {
406+
// Exponent too large to expand into a decimal string; emit scientific notation.
407+
const bigExponent = BigInt(rawExponent);
408+
if (mantissaDigits.length === 1) {
409+
numericString = sign + mantissaDigits + "e" + String(bigExponent);
410+
} else {
411+
const adjustedExp = bigExponent + BigInt(mantissaDigits.length - 1);
412+
numericString = sign + mantissaDigits[0] + "." + mantissaDigits.slice(1) + "e" + String(adjustedExp);
413+
}
397414
}
398-
numericString = sign + numericString;
399415

400416
// the new offset is the sum of:
401417
// 1. the local major offset (1)

packages/core/src/submodules/cbor/cbor-encode.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,14 +179,24 @@ export function encode(_input: any): void {
179179
continue;
180180
} else if (typeof input === "object") {
181181
if (input instanceof NumericValue) {
182-
const decimalIndex = input.string.indexOf(".");
183-
const exponent = decimalIndex === -1 ? 0 : decimalIndex - input.string.length + 1;
184-
const mantissa = BigInt(input.string.replace(".", ""));
182+
let str = input.string;
183+
let expOffset = BigInt(0);
184+
185+
const eIndex = str.search(/[eE]/);
186+
if (eIndex !== -1) {
187+
expOffset = BigInt(str.slice(eIndex + 1));
188+
str = str.slice(0, eIndex);
189+
}
190+
191+
const decimalIndex = str.indexOf(".");
192+
const fractionDigits = decimalIndex === -1 ? 0 : str.length - decimalIndex - 1;
193+
const exponent = expOffset - BigInt(fractionDigits);
194+
const mantissa = BigInt(str.replace(".", ""));
185195

186196
data[cursor++] = 0b110_00100; // major 6, tag 4.
187197
encodeInteger(majorList, 2);
188198
encodeStack.push(mantissa);
189-
encodeStack.push(exponent);
199+
encodeStack.push(exponent >= -0x20000000000000n && exponent <= 0x1fffffffffffffn ? Number(exponent) : exponent);
190200
continue;
191201
}
192202
if (input[tagSymbol]) {

packages/core/src/submodules/cbor/cbor.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,40 @@ describe("cbor", () => {
322322
]);
323323
});
324324

325+
it("should round-trip NumericValue with exponent notation", () => {
326+
for (const bigDecimal of ["1.5e10", "3E-20", "-2.0e+5", "100E3", "1e2", ".5e3"]) {
327+
const numericValue = new NumericValue(bigDecimal, "bigDecimal");
328+
const serialized = cbor.serialize(numericValue);
329+
330+
const major = serialized[0] >> 5;
331+
expect(major).toEqual(0b110); // 6
332+
333+
const tag = serialized[0] & 0b11111;
334+
expect(tag).toEqual(0b0100); // 4
335+
336+
const deserialized = cbor.deserialize(serialized);
337+
expect(deserialized).toBeInstanceOf(NumericValue);
338+
}
339+
});
340+
341+
it("should round-trip NumericValue with exponent exceeding safe integer range", () => {
342+
for (const bigDecimal of ["1e99999999999999999999", "-1e99999999999999999999"]) {
343+
const numericValue = new NumericValue(bigDecimal, "bigDecimal");
344+
const serialized = cbor.serialize(numericValue);
345+
346+
const major = serialized[0] >> 5;
347+
expect(major).toEqual(0b110); // 6
348+
349+
const tag = serialized[0] & 0b11111;
350+
expect(tag).toEqual(0b0100); // 4
351+
352+
const deserialized = cbor.deserialize(serialized);
353+
expect(deserialized).toBeInstanceOf(NumericValue);
354+
// Verify the exponent is preserved exactly (not lossy via Number coercion)
355+
expect(deserialized.string).toContain("99999999999999999999");
356+
}
357+
});
358+
325359
it("should round-trip sequences of big numbers", () => {
326360
const sequence = {
327361
map: {

packages/core/src/submodules/cbor/codec-v2/CborShapeDeserializer2.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -220,26 +220,42 @@ function readTag(ns: NormalizedSchema): any {
220220

221221
if (tagNumber === 4) {
222222
const docSchema = NormalizedSchema.of(15 satisfies DocumentSchema);
223-
const pair = readValue(docSchema) as [number, number | bigint];
224-
const [exponent, mantissa] = pair;
223+
const pair = readValue(docSchema) as [number | bigint, number | bigint];
224+
const [rawExponent, mantissa] = pair;
225225
const normalizer = mantissa < 0 ? -1 : 1;
226-
const mantissaStr = "0".repeat(Math.abs(exponent) + 1) + String(BigInt(normalizer) * BigInt(mantissa));
226+
const absMantissa = BigInt(normalizer) * BigInt(mantissa);
227+
const mantissaDigits = String(absMantissa);
228+
const sign = mantissa < 0 ? "-" : "";
227229

228230
let numericString: string;
229-
const sign = mantissa < 0 ? "-" : "";
230231

231-
numericString =
232-
exponent === 0
233-
? mantissaStr
234-
: mantissaStr.slice(0, mantissaStr.length + exponent) + "." + mantissaStr.slice(exponent);
235-
numericString = numericString.replace(/^0+/g, "");
236-
if (numericString === "") {
237-
numericString = "0";
238-
}
239-
if (numericString[0] === ".") {
240-
numericString = "0" + numericString;
232+
const isSmallExponent = typeof rawExponent === "number" && Math.abs(rawExponent) <= 1e15;
233+
if (isSmallExponent) {
234+
const exponent = rawExponent as number;
235+
const mantissaStr = "0".repeat(Math.abs(exponent) + 1) + mantissaDigits;
236+
237+
numericString =
238+
exponent === 0
239+
? mantissaStr
240+
: mantissaStr.slice(0, mantissaStr.length + exponent) + "." + mantissaStr.slice(exponent);
241+
numericString = numericString.replace(/^0+/g, "");
242+
if (numericString === "") {
243+
numericString = "0";
244+
}
245+
if (numericString[0] === ".") {
246+
numericString = "0" + numericString;
247+
}
248+
numericString = sign + numericString;
249+
} else {
250+
// Exponent too large to expand into a decimal string; emit scientific notation.
251+
const bigExponent = BigInt(rawExponent);
252+
if (mantissaDigits.length === 1) {
253+
numericString = sign + mantissaDigits + "e" + String(bigExponent);
254+
} else {
255+
const adjustedExp = bigExponent + BigInt(mantissaDigits.length - 1);
256+
numericString = sign + mantissaDigits[0] + "." + mantissaDigits.slice(1) + "e" + String(adjustedExp);
257+
}
241258
}
242-
numericString = sign + numericString;
243259

244260
return nv(numericString);
245261
}

packages/core/src/submodules/cbor/codec-v2/CborShapeSerializer2.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -701,16 +701,30 @@ function writeTag(tagValue: number | bigint, innerValue: unknown): void {
701701
}
702702

703703
function writeNumericValue(nv: NumericValue): void {
704-
const decimalIndex = nv.string.indexOf(".");
705-
const exponent = decimalIndex === -1 ? 0 : decimalIndex - nv.string.length + 1;
706-
const mantissa = BigInt(nv.string.replace(".", ""));
704+
let str = nv.string;
705+
let expOffset = BigInt(0);
706+
707+
const eIndex = str.search(/[eE]/);
708+
if (eIndex !== -1) {
709+
expOffset = BigInt(str.slice(eIndex + 1));
710+
str = str.slice(0, eIndex);
711+
}
712+
713+
const decimalIndex = str.indexOf(".");
714+
const fractionDigits = decimalIndex === -1 ? 0 : str.length - decimalIndex - 1;
715+
const exponent = expOffset - BigInt(fractionDigits);
716+
const mantissa = BigInt(str.replace(".", ""));
707717

708718
ensure(9);
709719
buf[cursor++] = 0b110_00100; // major 6, tag 4
710720
encodeHeader(majorList, 2);
711721

712722
ensure(9);
713-
writeInteger(exponent);
723+
if (exponent >= -0x20000000000000n && exponent <= 0x1fffffffffffffn) {
724+
writeInteger(Number(exponent));
725+
} else {
726+
writeBigInt(exponent);
727+
}
714728
writeBigInt(mantissa);
715729
}
716730

packages/core/src/submodules/cbor/codec-v2/SinglePassCbor.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,61 @@ describe("CborShapeSerializer2", () => {
188188
expect(cbor.deserialize(singleBytes)).toEqual(cbor.deserialize(multiBytes));
189189
});
190190

191+
it("serializes NumericValue with exponent notation", () => {
192+
const schema = [
193+
3,
194+
"ns",
195+
"Measurement",
196+
0,
197+
["value"],
198+
[19 satisfies BigDecimalSchema],
199+
] satisfies StaticStructureSchema;
200+
201+
const cases = ["1.5e10", "3E-20", "-2.0e+5", "100E3", "1e2", ".5e3"];
202+
for (const str of cases) {
203+
const data = { value: nv(str) };
204+
205+
multiPass.write(schema, data);
206+
const multiBytes = multiPass.flush();
207+
208+
singlePass.write(schema, data);
209+
const singleBytes = singlePass.flush();
210+
211+
const multiResult = cbor.deserialize(multiBytes);
212+
const singleResult = cbor.deserialize(singleBytes);
213+
expect(singleResult).toEqual(multiResult);
214+
expect(singleResult.value.string).toEqual(multiResult.value.string);
215+
}
216+
});
217+
218+
it("serializes NumericValue with exponent exceeding safe integer range", () => {
219+
const schema = [
220+
3,
221+
"ns",
222+
"Measurement",
223+
0,
224+
["value"],
225+
[19 satisfies BigDecimalSchema],
226+
] satisfies StaticStructureSchema;
227+
228+
const cases = ["1e99999999999999999999", "-1e99999999999999999999"];
229+
for (const str of cases) {
230+
const data = { value: nv(str) };
231+
232+
multiPass.write(schema, data);
233+
const multiBytes = multiPass.flush();
234+
235+
singlePass.write(schema, data);
236+
const singleBytes = singlePass.flush();
237+
238+
const multiResult = cbor.deserialize(multiBytes);
239+
const singleResult = cbor.deserialize(singleBytes);
240+
expect(singleResult).toEqual(multiResult);
241+
// Verify the exponent is preserved exactly (not lossy via Number coercion)
242+
expect(singleResult.value.string).toContain("99999999999999999999");
243+
}
244+
});
245+
191246
it("serializes unions with $unknown", () => {
192247
const unionSchema = [4, "ns", "Union", 0, ["a", "b"], [0, 0]] satisfies StaticUnionSchema;
193248
const data = { $unknown: ["c", "hello"] };
@@ -391,6 +446,44 @@ describe("CborShapeDeserializer2", () => {
391446
expect(result).toEqual(data);
392447
});
393448

449+
it("deserializes NumericValue with exponent notation", () => {
450+
const schema = [
451+
3,
452+
"ns",
453+
"Measurement",
454+
0,
455+
["value"],
456+
[19 satisfies BigDecimalSchema],
457+
] satisfies StaticStructureSchema;
458+
459+
const cases = ["1.5e10", "3E-20", "-2.0e+5", "100E3", "1e2", ".5e3"];
460+
for (const str of cases) {
461+
const data = { value: nv(str) };
462+
const result = assertEquivalentDeserialization(schema, data);
463+
expect(result.value).toBeInstanceOf(Object);
464+
expect(result.value.string).toBeDefined();
465+
}
466+
});
467+
468+
it("deserializes NumericValue with exponent exceeding safe integer range", () => {
469+
const schema = [
470+
3,
471+
"ns",
472+
"Measurement",
473+
0,
474+
["value"],
475+
[19 satisfies BigDecimalSchema],
476+
] satisfies StaticStructureSchema;
477+
478+
const cases = ["1e99999999999999999999", "-1e99999999999999999999", "1e-99999999999999999999"];
479+
for (const str of cases) {
480+
const data = { value: nv(str) };
481+
const result = assertEquivalentDeserialization(schema, data);
482+
expect(result.value).toBeInstanceOf(Object);
483+
expect(result.value.string).toContain("99999999999999999999");
484+
}
485+
});
486+
394487
it("deserializes unknown union members to $unknown", () => {
395488
const schema = [
396489
3,

0 commit comments

Comments
 (0)