@bcts/dcbor 1.0.0-beta.2 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bcts/dcbor",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.4",
4
4
  "type": "module",
5
5
  "description": "Blockchain Commons Deterministic CBOR (dCBOR) for TypeScript",
6
6
  "license": "BSD-2-Clause-Patent",
@@ -40,7 +40,7 @@
40
40
  "typecheck": "tsc --noEmit",
41
41
  "clean": "rm -rf dist",
42
42
  "docs": "typedoc",
43
- "prepublishOnly": "npm run clean && npm run build && npm test"
43
+ "prepublishOnly": "npm run build"
44
44
  },
45
45
  "keywords": [
46
46
  "cbor",
@@ -60,10 +60,10 @@
60
60
  "@bcts/tsconfig": "^0.1.0",
61
61
  "@eslint/js": "^10.0.1",
62
62
  "@types/collections": "^5.1.5",
63
- "@types/node": "^25.9.3",
64
- "@typescript-eslint/eslint-plugin": "^8.61.1",
65
- "@typescript-eslint/parser": "^8.61.1",
66
- "eslint": "^10.5.0",
63
+ "@types/node": "^26.0.1",
64
+ "@typescript-eslint/eslint-plugin": "^8.62.0",
65
+ "@typescript-eslint/parser": "^8.62.0",
66
+ "eslint": "^10.6.0",
67
67
  "ts-node": "^10.9.2",
68
68
  "tsdown": "^0.22.3",
69
69
  "typedoc": "^0.28.19",
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { type Cbor, MajorType } from "./cbor";
19
19
  import type { CborTagged } from "./cbor-tagged";
20
- import type { Tag } from "./tag";
20
+ import { tagValuesEqual, type Tag } from "./tag";
21
21
  import { CborError } from "./error";
22
22
  import { decodeCbor } from "./decode";
23
23
 
@@ -160,7 +160,7 @@ export const validateTag = (cbor: Cbor, expectedTags: Tag[]): Tag => {
160
160
  }
161
161
 
162
162
  const tagValue = cbor.tag;
163
- const matchingTag = expectedTags.find((t) => t.value === tagValue);
163
+ const matchingTag = expectedTags.find((t) => tagValuesEqual(t.value, tagValue));
164
164
  if (matchingTag === undefined) {
165
165
  // Mirror Rust's `Error::WrongTag(expected, actual)` — produce the
166
166
  // structured WrongTag variant rather than a stringly-typed Custom
package/src/cbor.ts CHANGED
@@ -12,7 +12,7 @@ import { encodeVarInt } from "./varint";
12
12
  import { concatBytes } from "./stdlib";
13
13
  import { bytesToHex, hexOpt } from "./dump";
14
14
  import { hexToBytes } from "./dump";
15
- import type { Tag } from "./tag";
15
+ import { tagValuesEqual, type Tag } from "./tag";
16
16
  import type { ByteString } from "./byte-string";
17
17
  import type { CborDate } from "./date";
18
18
  import { diagnosticOpt } from "./diag";
@@ -104,6 +104,10 @@ export type CborInput =
104
104
  | CborInput[]
105
105
  | Map<unknown, unknown>
106
106
  | Set<unknown>
107
+ // Values that convert themselves to CBOR (e.g. untagged CborSet or a
108
+ // tagged-encodable type); `cbor()` dispatches to these at runtime.
109
+ | ToCbor
110
+ | TaggedCborEncodable
107
111
  | Record<string, unknown>;
108
112
 
109
113
  export const isCborNumber = (value: unknown): value is CborNumber => {
@@ -377,12 +381,19 @@ export const cbor = (value: CborInput): Cbor => {
377
381
  } else if (value == -Infinity) {
378
382
  result = { isCbor: true, type: MajorType.Simple, value: { type: "Float", value: -Infinity } };
379
383
  } else if (typeof value === "number" && !Number.isSafeInteger(value)) {
380
- // Mirrors Rust `f64::exact_from_f64`: any finite, integer-valued
381
- // float that exceeds the safe-integer range (so adjacent integers
382
- // can't be distinguished, e.g. `1e21`) cannot be encoded as a
383
- // canonical integer. Route to Float instead Rust's `From<f64>
384
- // for CBOR` does the same fallback.
385
- result = { isCbor: true, type: MajorType.Simple, value: { type: "Float", value: value } };
384
+ // A finite, whole-valued `number` beyond the safe-integer range (NaN,
385
+ // ±Infinity, and fractional values are handled above). Like Rust's
386
+ // `From<f64> for CBOR`, reduce any whole value in `[-(2^64), 2^64)` to a
387
+ // CBOR integer; values outside that range (e.g. `1e21`) stay floats.
388
+ // Go through BigInt so we don't lose precision above 2^53.
389
+ const big = BigInt(value);
390
+ if (big >= 0n && big <= 0xffffffffffffffffn) {
391
+ result = { isCbor: true, type: MajorType.Unsigned, value: big };
392
+ } else if (big < 0n && big >= -0x10000000000000000n) {
393
+ result = { isCbor: true, type: MajorType.Negative, value: -big - 1n };
394
+ } else {
395
+ result = { isCbor: true, type: MajorType.Simple, value: { type: "Float", value: value } };
396
+ }
386
397
  } else if (
387
398
  typeof value === "bigint" &&
388
399
  (value > 0xffffffffffffffffn || value < -0x10000000000000000n)
@@ -817,7 +828,7 @@ export const attachMethods = <T extends Omit<Cbor, keyof CborMethods>>(obj: T):
817
828
  typeof expectedTag === "object" && "value" in expectedTag
818
829
  ? expectedTag
819
830
  : { value: expectedTag };
820
- if (this.tag !== expected.value) {
831
+ if (!tagValuesEqual(this.tag, expected.value)) {
821
832
  // Mirror Rust `Error::WrongTag(expected, actual)`.
822
833
  throw new CborError({
823
834
  type: "WrongTag",
@@ -837,7 +848,7 @@ export const attachMethods = <T extends Omit<Cbor, keyof CborMethods>>(obj: T):
837
848
  throw new CborError({ type: "WrongType" });
838
849
  }
839
850
  const tagValue = this.tag;
840
- const matchingTag = expectedTags.find((t) => t.value === tagValue);
851
+ const matchingTag = expectedTags.find((t) => tagValuesEqual(t.value, tagValue));
841
852
  if (matchingTag === undefined) {
842
853
  // Mirror Rust `Error::WrongTag(expected, actual)`.
843
854
  throw new CborError({
@@ -29,6 +29,7 @@ import {
29
29
  import type { CborMap } from "./map";
30
30
  import { isFloat as isSimpleFloat } from "./simple";
31
31
  import { decodeCbor } from "./decode";
32
+ import { ExactF64 } from "./exact";
32
33
  import { CborError } from "./error";
33
34
 
34
35
  // ============================================================================
@@ -358,12 +359,20 @@ export const asBoolean = (cbor: Cbor): boolean | undefined => {
358
359
  * @returns Float or undefined
359
360
  */
360
361
  export const asFloat = (cbor: Cbor): number | undefined => {
361
- if (cbor.type !== MajorType.Simple) {
362
- return undefined;
362
+ // Integers coerce to float too, matching Rust's `TryFrom<CBOR> for f64`.
363
+ // dCBOR reduces whole-valued floats to integers (42.0 encodes as 42), so a
364
+ // float-only accessor would wrongly reject them. Returns undefined for
365
+ // non-numeric types and for integers not exactly representable as f64.
366
+ if (cbor.type === MajorType.Unsigned) {
367
+ return ExactF64.exactFromU64(cbor.value);
363
368
  }
364
- const simple = cbor.value;
365
- if (isSimpleFloat(simple)) {
366
- return simple.value;
369
+ if (cbor.type === MajorType.Negative) {
370
+ // cbor.value holds the raw magnitude n; the actual value is -1 - n.
371
+ const f = ExactF64.exactFromU64(cbor.value);
372
+ return f === undefined ? undefined : -1 - f;
373
+ }
374
+ if (cbor.type === MajorType.Simple) {
375
+ return isSimpleFloat(cbor.value) ? cbor.value.value : undefined;
367
376
  }
368
377
  return undefined;
369
378
  };
@@ -527,11 +536,20 @@ export const expectBoolean = (cbor: Cbor): boolean => {
527
536
  * @throws {CborError} With type 'WrongType' if cbor is not a float
528
537
  */
529
538
  export const expectFloat = (cbor: Cbor): number => {
530
- const value = asFloat(cbor);
531
- if (value === undefined) {
532
- throw new CborError({ type: "WrongType" });
539
+ // Numeric types coerce to float (OutOfRange if an integer isn't exactly
540
+ // representable as f64); anything else is WrongType. Matches Rust's
541
+ // `TryFrom<CBOR> for f64`.
542
+ if (cbor.type === MajorType.Unsigned || cbor.type === MajorType.Negative) {
543
+ const value = asFloat(cbor);
544
+ if (value === undefined) {
545
+ throw new CborError({ type: "OutOfRange" });
546
+ }
547
+ return value;
533
548
  }
534
- return value;
549
+ if (cbor.type === MajorType.Simple && isSimpleFloat(cbor.value)) {
550
+ return cbor.value.value;
551
+ }
552
+ throw new CborError({ type: "WrongType" });
535
553
  };
536
554
 
537
555
  /**
@@ -722,7 +740,7 @@ export const hasTag = (cbor: Cbor, tag: number | bigint): boolean => {
722
740
  if (cbor.type !== MajorType.Tagged) {
723
741
  return false;
724
742
  }
725
- return cbor.tag === tag;
743
+ return tagValuesEqual(cbor.tag, tag);
726
744
  };
727
745
 
728
746
  /**
@@ -733,7 +751,7 @@ export const hasTag = (cbor: Cbor, tag: number | bigint): boolean => {
733
751
  * @returns Tagged content or undefined
734
752
  */
735
753
  export const getTaggedContent = (cbor: Cbor, tag: number | bigint): Cbor | undefined => {
736
- if (cbor.type === MajorType.Tagged && cbor.tag === tag) {
754
+ if (cbor.type === MajorType.Tagged && tagValuesEqual(cbor.tag, tag)) {
737
755
  return cbor.value;
738
756
  }
739
757
  return undefined;
@@ -754,7 +772,7 @@ export const expectTaggedContent = (cbor: Cbor, tag: number | bigint): Cbor => {
754
772
  if (cbor.type !== MajorType.Tagged) {
755
773
  throw new CborError({ type: "WrongType" });
756
774
  }
757
- if (cbor.tag !== tag) {
775
+ if (!tagValuesEqual(cbor.tag, tag)) {
758
776
  throw new CborError({
759
777
  type: "WrongTag",
760
778
  expected: { value: tag },
@@ -769,7 +787,7 @@ export const expectTaggedContent = (cbor: Cbor, tag: number | bigint): Cbor => {
769
787
  // These functions provide the API expected by the envelope package
770
788
  // ============================================================================
771
789
 
772
- import type { Tag } from "./tag";
790
+ import { tagValuesEqual, type Tag } from "./tag";
773
791
  import { getGlobalTagsStore } from "./tags-store";
774
792
 
775
793
  /**
package/src/date.ts CHANGED
@@ -33,6 +33,34 @@ import {
33
33
  } from "./cbor-tagged";
34
34
  import { CborError } from "./error";
35
35
 
36
+ /**
37
+ * Normalize a timestamp (seconds since the Unix epoch) to whole seconds plus a
38
+ * non-negative, sub-second nanosecond part, matching Rust's
39
+ * `Date::from_timestamp` so dates round-trip byte-identically with the reference.
40
+ *
41
+ * The nanosecond part is computed like Rust's `as u32` cast: truncated toward
42
+ * zero and clamped to [0, u32::MAX]. So a negative fraction floors the value
43
+ * (`-1.5` becomes `-1.0`) and sub-nanosecond precision is dropped
44
+ * (`1.0000000005` becomes `1.0`).
45
+ *
46
+ * @internal
47
+ */
48
+ function normalizeTimestampSeconds(seconds: number): number {
49
+ if (!Number.isFinite(seconds)) {
50
+ // chrono has no representation for a non-finite instant; reject with a
51
+ // typed error.
52
+ throw new CborError({ type: "InvalidDate", message: "non-finite timestamp" });
53
+ }
54
+ const whole = Math.trunc(seconds);
55
+ let nsecs = Math.trunc((seconds - whole) * 1_000_000_000);
56
+ if (nsecs < 0) {
57
+ nsecs = 0;
58
+ } else if (nsecs > 0xffffffff) {
59
+ nsecs = 0xffffffff;
60
+ }
61
+ return whole + nsecs / 1_000_000_000;
62
+ }
63
+
36
64
  /**
37
65
  * A CBOR-friendly representation of a date and time.
38
66
  *
@@ -192,7 +220,9 @@ export class CborDate implements CborTagged, CborTaggedEncodable, CborTaggedDeco
192
220
  */
193
221
  static fromTimestamp(secondsSinceUnixEpoch: number): CborDate {
194
222
  const instance = new CborDate();
195
- instance._seconds = secondsSinceUnixEpoch;
223
+ // Normalize on construction so the stored value (and thus its encoding,
224
+ // equality, and ordering) matches the reference.
225
+ instance._seconds = normalizeTimestampSeconds(secondsSinceUnixEpoch);
196
226
  return instance;
197
227
  }
198
228
 
@@ -222,12 +252,40 @@ export class CborDate implements CborTagged, CborTaggedEncodable, CborTaggedDeco
222
252
  * ```
223
253
  */
224
254
  static fromString(value: string): CborDate {
225
- // Try parsing as ISO 8601 date string
226
- const dt = new Date(value);
227
- if (isNaN(dt.getTime())) {
228
- throw new CborError({ type: "InvalidDate", message: "Invalid date string" });
255
+ // Accept only strict RFC-3339 date-times (with seconds and an explicit
256
+ // `Z`/±HH:MM offset) or bare `YYYY-MM-DD` dates (read as UTC midnight),
257
+ // matching Rust's `Date::from_string`. The plain `new Date()` parser is far
258
+ // more lenient (and engine-dependent), so we gate it behind explicit regexes.
259
+ const invalidDate = new CborError({ type: "InvalidDate", message: "Invalid date string" });
260
+
261
+ // RFC-3339 date-time: `YYYY-MM-DDThh:mm:ss[.frac](Z|±hh:mm)`.
262
+ const rfc3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
263
+ // Date-only: `YYYY-MM-DD`.
264
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
265
+
266
+ let parsed: Date;
267
+ if (rfc3339.test(value)) {
268
+ parsed = new Date(value);
269
+ } else if (dateOnly.test(value)) {
270
+ // Treat a bare date as UTC midnight (chrono uses 00:00:00 UTC).
271
+ parsed = new Date(`${value}T00:00:00Z`);
272
+ } else {
273
+ throw invalidDate;
229
274
  }
230
- return CborDate.fromDatetime(dt);
275
+
276
+ // `new Date` rolls impossible dates over (`2023-02-30` becomes Mar 2)
277
+ // rather than failing, so check the `YYYY-MM-DD` portion is a real calendar
278
+ // date. The first 10 chars are always `YYYY-MM-DD` given the regexes above.
279
+ const [y, m, d] = value.slice(0, 10).split("-").map(Number);
280
+ const probe = new Date(Date.UTC(y, m - 1, d));
281
+ const calendarValid =
282
+ probe.getUTCFullYear() === y && probe.getUTCMonth() === m - 1 && probe.getUTCDate() === d;
283
+
284
+ // ...and reject any residual unparseable input (e.g. an out-of-range time).
285
+ if (!calendarValid || isNaN(parsed.getTime())) {
286
+ throw invalidDate;
287
+ }
288
+ return CborDate.fromDatetime(parsed);
231
289
  }
232
290
 
233
291
  /**
@@ -439,7 +497,9 @@ export class CborDate implements CborTagged, CborTaggedEncodable, CborTaggedDeco
439
497
  throw new CborError({ type: "WrongType" });
440
498
  }
441
499
 
442
- this._seconds = timestamp;
500
+ // Normalize the decoded value so it re-encodes to the same bytes as the
501
+ // reference (e.g. a tag-1 float of -1.5 decodes and re-encodes as integer -1).
502
+ this._seconds = normalizeTimestampSeconds(timestamp);
443
503
  return this;
444
504
  }
445
505
 
package/src/decode.ts CHANGED
@@ -40,7 +40,12 @@ function at(data: DataView, index: number): number {
40
40
  }
41
41
 
42
42
  function from(data: DataView, index: number): DataView {
43
- return new DataView(data.buffer, data.byteOffset + index);
43
+ // Bound the sub-view to the remaining logical bytes; without a length it
44
+ // would span the whole backing ArrayBuffer, letting a nested decode read
45
+ // past the input's end when the input is itself a slice of a larger buffer.
46
+ // This matches Rust's `&data[index..]`, so an over-long inner item fails as
47
+ // an Underrun at its source instead of only at the top-level length check.
48
+ return new DataView(data.buffer, data.byteOffset + index, data.byteLength - index);
44
49
  }
45
50
 
46
51
  function range(data: DataView, start: number, end: number): DataView {
@@ -165,7 +170,18 @@ function decodeCborInternal(data: DataView): { cbor: Cbor; len: number } {
165
170
  throw new CborError({ type: "OutOfRange" });
166
171
  }
167
172
  const textBuf = parseBytes(from(data, varIntLen), textLen);
168
- const text = new TextDecoder().decode(textBuf);
173
+ // dCBOR text strings must be valid UTF-8 (RFC 8949). Use a fatal decoder
174
+ // so invalid bytes throw rather than getting replaced with U+FFFD and
175
+ // silently accepted, matching the Rust reference's InvalidUtf8 rejection.
176
+ let text: string;
177
+ try {
178
+ text = new TextDecoder("utf-8", { fatal: true }).decode(textBuf);
179
+ } catch (e) {
180
+ throw new CborError({
181
+ type: "InvalidUtf8",
182
+ message: e instanceof Error ? e.message : String(e),
183
+ });
184
+ }
169
185
  // dCBOR requires all text strings to be in Unicode Normalization Form C (NFC)
170
186
  // Reject any strings that are not already in NFC form
171
187
  if (text.normalize("NFC") !== text) {
package/src/diag.ts CHANGED
@@ -16,8 +16,10 @@
16
16
 
17
17
  import { type Cbor, MajorType, type Simple } from "./cbor";
18
18
  import { bytesToHex } from "./dump";
19
+ import { errorToString } from "./error";
20
+ import { floatDisplayString } from "./float";
19
21
  import type { CborMap } from "./map";
20
- import { getGlobalTagsStore, type TagsStore } from "./tags-store";
22
+ import { getGlobalTagsStore, type TagsStore, type TagsStoreOpt } from "./tags-store";
21
23
  import type { Tag } from "./tag";
22
24
  import type { WalkElement } from "./walk";
23
25
  import { flanked } from "./string-util";
@@ -63,7 +65,7 @@ export interface DiagFormatOpts {
63
65
  *
64
66
  * @default 'global'
65
67
  */
66
- tags?: TagsStore | "global" | "none";
68
+ tags?: TagsStoreOpt;
67
69
 
68
70
  /**
69
71
  * Current indentation level (internal use for recursion).
@@ -412,13 +414,9 @@ function item_tagged(tag: number | bigint, content: Cbor, opts: DiagFormatOpts):
412
414
  if (result.ok) {
413
415
  return item(result.value);
414
416
  }
415
- const errorMsg =
416
- result.error.type === "Custom"
417
- ? result.error.message
418
- : result.error.type === "WrongTag"
419
- ? `expected CBOR tag ${result.error.expected.value}, but got ${result.error.actual.value}`
420
- : result.error.type;
421
- return item(`<error: ${errorMsg}>`);
417
+ // Use the shared error formatter so every variant gets its full message,
418
+ // including name-aware tag rendering for WrongTag (matches Rust).
419
+ return item(`<error: ${errorToString(result.error)}>`);
422
420
  }
423
421
  }
424
422
 
@@ -450,12 +448,9 @@ function formatBytes(value: Uint8Array): string {
450
448
  }
451
449
 
452
450
  function formatText(value: string): string {
453
- const escaped = value
454
- .replace(/\\/g, "\\\\")
455
- .replace(/"/g, '\\"')
456
- .replace(/\n/g, "\\n")
457
- .replace(/\r/g, "\\r")
458
- .replace(/\t/g, "\\t");
451
+ // Match Rust `format_string` (cbor.rs): only the double-quote is escaped;
452
+ // backslash, tab, newline, and carriage return are emitted verbatim.
453
+ const escaped = value.replace(/"/g, '\\"');
459
454
  return `"${escaped}"`;
460
455
  }
461
456
 
@@ -473,26 +468,14 @@ function formatSimple(value: Simple): string {
473
468
  }
474
469
 
475
470
  /**
476
- * Format a finite CBOR float to match Rust `Simple::format!("{:?}", v)`.
477
- *
478
- * - `1.0` → `"1.0"` (Rust Debug). JS `String(1.0)` gives `"1"` so we append `.0`.
479
- * - `1.5` → `"1.5"`.
480
- * - `1e100` → `"1e100"` (Rust uses no `+` sign in the exponent). JS uses `1e+100`.
481
- * - Specials (NaN / ±Infinity) produce the exact Rust strings.
471
+ * Format a CBOR float for diagnostic output. Shared with the hex-dump
472
+ * annotation path; see {@link floatDisplayString}.
482
473
  */
483
474
  function formatFloat(value: number): string {
484
- if (Number.isNaN(value)) return "NaN";
485
- if (!Number.isFinite(value)) return value > 0 ? "Infinity" : "-Infinity";
486
- let str = String(value);
487
- // Strip the JS-only `+` in scientific exponents to match Rust Debug format.
488
- str = str.replace(/e\+/, "e");
489
- if (!str.includes(".") && !str.includes("e")) {
490
- str = `${str}.0`;
491
- }
492
- return str;
475
+ return floatDisplayString(value);
493
476
  }
494
477
 
495
- function resolveTagsStore(tags?: TagsStore | "global" | "none"): TagsStore | undefined {
478
+ function resolveTagsStore(tags?: TagsStoreOpt): TagsStore | undefined {
496
479
  if (tags === "none") return undefined;
497
480
  if (tags === "global" || tags === undefined) return getGlobalTagsStore();
498
481
  return tags;
package/src/dump.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { type Cbor, MajorType, cborData } from "./cbor";
16
16
  import { encodeVarInt } from "./varint";
17
+ import { floatDisplayString } from "./float";
17
18
  import { flanked, sanitized } from "./string-util";
18
19
  import type { TagsStore } from "./tags-store";
19
20
  import { getGlobalTagsStore } from "./tags-store";
@@ -274,7 +275,8 @@ function dumpItems(cbor: Cbor, level: number, opts: HexFormatOpts): DumpItem[] {
274
275
  } else if (simple.type === "Null") {
275
276
  note = "null";
276
277
  } else if (simple.type === "Float") {
277
- note = `${simple.value}`;
278
+ // Use the same float formatting as diagnostic output, not raw JS coercion.
279
+ note = floatDisplayString(simple.value);
278
280
  } else {
279
281
  note = "simple";
280
282
  }
package/src/exact.ts CHANGED
@@ -25,6 +25,37 @@ const hasFract = (n: number): boolean => {
25
25
  return n % 1 !== 0;
26
26
  };
27
27
 
28
+ const U64_MAX_BIG = 0xffffffffffffffffn;
29
+ const I64_MAX_BIG = 0x7fffffffffffffffn;
30
+ const I64_MIN_BIG = -0x8000000000000000n;
31
+
32
+ /**
33
+ * Truncate a float to u64 with Rust's saturating `as u64` semantics: NaN and
34
+ * negatives clamp to 0, values at or above 2^64 clamp to u64::MAX. This lets the
35
+ * exact-float checks below mirror Rust's `(f as u64) == source` round-trip, where
36
+ * u64::MAX rounds to 2^64 in float but saturates back to u64::MAX.
37
+ */
38
+ const saturateFloatToU64 = (f: number): bigint => {
39
+ if (Number.isNaN(f)) return 0n;
40
+ const t = Math.trunc(f);
41
+ if (t <= 0) return 0n;
42
+ const big = BigInt(t);
43
+ return big > U64_MAX_BIG ? U64_MAX_BIG : big;
44
+ };
45
+
46
+ /**
47
+ * Truncate a float to i64 with Rust's saturating `as i64` semantics: NaN clamps
48
+ * to 0, values clamp to i64::MAX or i64::MIN at the bounds.
49
+ */
50
+ const saturateFloatToI64 = (f: number): bigint => {
51
+ if (Number.isNaN(f)) return 0n;
52
+ const t = Math.trunc(f);
53
+ const big = BigInt(t);
54
+ if (big > I64_MAX_BIG) return I64_MAX_BIG;
55
+ if (big < I64_MIN_BIG) return I64_MIN_BIG;
56
+ return big;
57
+ };
58
+
28
59
  /**
29
60
  * Exact conversions for i16 (-32768 to 32767).
30
61
  */
@@ -529,19 +560,18 @@ export class ExactF32 {
529
560
  }
530
561
 
531
562
  static exactFromU64(source: number | bigint): number | undefined {
532
- const n = typeof source === "bigint" ? Number(source) : source;
533
- const f32Bytes = numberToBinary32(n);
534
- const f = binary32ToNumber(f32Bytes);
535
- const roundTrip = typeof source === "bigint" ? BigInt(Math.trunc(f)) : Math.trunc(f);
536
- return roundTrip === source ? f : undefined;
563
+ const srcBig = typeof source === "bigint" ? source : BigInt(source);
564
+ const f = binary32ToNumber(numberToBinary32(Number(srcBig)));
565
+ if (!Number.isFinite(f)) return undefined;
566
+ // Saturating round-trip so u64::MAX (which rounds up to 2^64 here) still matches.
567
+ return saturateFloatToU64(f) === srcBig ? f : undefined;
537
568
  }
538
569
 
539
570
  static exactFromI64(source: number | bigint): number | undefined {
540
- const n = typeof source === "bigint" ? Number(source) : source;
541
- const f32Bytes = numberToBinary32(n);
542
- const f = binary32ToNumber(f32Bytes);
543
- const roundTrip = typeof source === "bigint" ? BigInt(Math.trunc(f)) : Math.trunc(f);
544
- return roundTrip === source ? f : undefined;
571
+ const srcBig = typeof source === "bigint" ? source : BigInt(source);
572
+ const f = binary32ToNumber(numberToBinary32(Number(srcBig)));
573
+ if (!Number.isFinite(f)) return undefined;
574
+ return saturateFloatToI64(f) === srcBig ? f : undefined;
545
575
  }
546
576
 
547
577
  static exactFromU128(source: bigint): number | undefined {
@@ -594,15 +624,18 @@ export class ExactF64 {
594
624
  }
595
625
 
596
626
  static exactFromU64(source: number | bigint): number | undefined {
597
- const n = typeof source === "bigint" ? Number(source) : source;
598
- const roundTrip = typeof source === "bigint" ? BigInt(Math.trunc(n)) : Math.trunc(n);
599
- return roundTrip === source ? n : undefined;
627
+ const srcBig = typeof source === "bigint" ? source : BigInt(source);
628
+ const n = Number(srcBig);
629
+ if (!Number.isFinite(n)) return undefined;
630
+ // Saturating round-trip so u64::MAX (which rounds up to 2^64 here) still matches.
631
+ return saturateFloatToU64(n) === srcBig ? n : undefined;
600
632
  }
601
633
 
602
634
  static exactFromI64(source: number | bigint): number | undefined {
603
- const n = typeof source === "bigint" ? Number(source) : source;
604
- const roundTrip = typeof source === "bigint" ? BigInt(Math.trunc(n)) : Math.trunc(n);
605
- return roundTrip === source ? n : undefined;
635
+ const srcBig = typeof source === "bigint" ? source : BigInt(source);
636
+ const n = Number(srcBig);
637
+ if (!Number.isFinite(n)) return undefined;
638
+ return saturateFloatToI64(n) === srcBig ? n : undefined;
606
639
  }
607
640
 
608
641
  static exactFromU128(source: bigint): number | undefined {