@marcuspuchalla/nachos 0.1.3 → 0.2.0

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/dist/{chunk-PTWN7K3Y.cjs → chunk-3Z45RBZP.cjs} +469 -244
  3. package/dist/chunk-3Z45RBZP.cjs.map +1 -0
  4. package/dist/{chunk-2MTLSQ7E.js → chunk-EDXZTSIA.js} +224 -166
  5. package/dist/chunk-EDXZTSIA.js.map +1 -0
  6. package/dist/{chunk-R62CQQNI.cjs → chunk-HMUA5KLG.cjs} +239 -181
  7. package/dist/chunk-HMUA5KLG.cjs.map +1 -0
  8. package/dist/{chunk-ZDZ2B5PE.js → chunk-JESIF5IF.js} +7 -3
  9. package/dist/chunk-JESIF5IF.js.map +1 -0
  10. package/dist/{chunk-5A5T56JB.js → chunk-LWNWC2O7.js} +442 -217
  11. package/dist/chunk-LWNWC2O7.js.map +1 -0
  12. package/dist/{chunk-PD72MVTX.cjs → chunk-P6A2OOIY.cjs} +7 -3
  13. package/dist/chunk-P6A2OOIY.cjs.map +1 -0
  14. package/dist/encoder/index.cjs +14 -14
  15. package/dist/encoder/index.d.cts +5 -4
  16. package/dist/encoder/index.d.ts +5 -4
  17. package/dist/encoder/index.js +2 -2
  18. package/dist/index.cjs +58 -39
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +40 -21
  21. package/dist/index.d.ts +40 -21
  22. package/dist/index.js +37 -17
  23. package/dist/index.js.map +1 -1
  24. package/dist/metafile-cjs.json +1 -1
  25. package/dist/metafile-esm.json +1 -1
  26. package/dist/parser/index.cjs +21 -21
  27. package/dist/parser/index.d.cts +4 -2
  28. package/dist/parser/index.d.ts +4 -2
  29. package/dist/parser/index.js +2 -2
  30. package/dist/{types-DvNlfbKB.d.cts → types-eG2qalpr.d.cts} +27 -1
  31. package/dist/{types-DvNlfbKB.d.ts → types-eG2qalpr.d.ts} +27 -1
  32. package/dist/{useCborSimpleEncoder-TVxzNJ_9.d.ts → useCborSimpleEncoder-CamvS-_N.d.ts} +7 -3
  33. package/dist/{useCborSimpleEncoder-ButVU988.d.cts → useCborSimpleEncoder-DXgPx62d.d.cts} +7 -3
  34. package/dist/{useCborTag-xV2Pz2VY.d.ts → useCborTag-D4d7xG3-.d.cts} +9 -4
  35. package/dist/{useCborTag-Cs1CZuXZ.d.cts → useCborTag-TYst1KR6.d.ts} +9 -4
  36. package/package.json +1 -1
  37. package/src/__tests__/audit-fixes.test.ts +141 -0
  38. package/src/__tests__/public-api.test.ts +153 -0
  39. package/src/__tests__/roundtrip.test.ts +5 -6
  40. package/src/encoder/__tests__/cbor-collection-encoder.test.ts +103 -5
  41. package/src/encoder/__tests__/cbor-encoder-errors.test.ts +40 -5
  42. package/src/encoder/__tests__/cbor-simple-encoder.test.ts +126 -0
  43. package/src/encoder/composables/useCborCollectionEncoder.ts +30 -26
  44. package/src/encoder/composables/useCborEncoder.ts +40 -0
  45. package/src/encoder/composables/useCborSimpleEncoder.ts +40 -9
  46. package/src/encoder/types.ts +9 -4
  47. package/src/encoder/utils.ts +33 -1
  48. package/src/index.ts +39 -20
  49. package/src/parser/__tests__/buffer-native-parsing.test.ts +338 -0
  50. package/src/parser/__tests__/cbor-map-duplicate-keys.test.ts +97 -7
  51. package/src/parser/__tests__/cbor-security-dos-protection.test.ts +164 -31
  52. package/src/parser/__tests__/cbor-standard-tags.test.ts +75 -7
  53. package/src/parser/__tests__/cbor-tag-reparse-fix.test.ts +268 -0
  54. package/src/parser/__tests__/utils-errors.test.ts +11 -3
  55. package/src/parser/composables/useCborCollection.ts +51 -45
  56. package/src/parser/composables/useCborDiagnostic.ts +28 -0
  57. package/src/parser/composables/useCborFloat.ts +2 -1
  58. package/src/parser/composables/useCborInteger.ts +24 -10
  59. package/src/parser/composables/useCborParser.ts +448 -208
  60. package/src/parser/composables/useCborTag.ts +53 -38
  61. package/src/parser/types.ts +32 -1
  62. package/src/parser/utils.ts +52 -0
  63. package/dist/chunk-2MTLSQ7E.js.map +0 -1
  64. package/dist/chunk-5A5T56JB.js.map +0 -1
  65. package/dist/chunk-PD72MVTX.cjs.map +0 -1
  66. package/dist/chunk-PTWN7K3Y.cjs.map +0 -1
  67. package/dist/chunk-R62CQQNI.cjs.map +0 -1
  68. package/dist/chunk-ZDZ2B5PE.js.map +0 -1
@@ -285,6 +285,100 @@ describe('CBOR Collection Encoder', () => {
285
285
  .toThrow('Duplicate map key detected')
286
286
  })
287
287
  })
288
+
289
+ describe('Canonical sort pre-encoding optimization', () => {
290
+ it('should produce identical output for canonical sort with many keys', () => {
291
+ const { encodeMap } = useCborCollectionEncoder({ canonical: true })
292
+
293
+ // Use many keys to exercise sorting — result must be deterministic
294
+ const map = new Map<EncodableValue, EncodableValue>([
295
+ ['zebra', 1],
296
+ ['apple', 2],
297
+ ['mango', 3],
298
+ ['banana', 4],
299
+ ['kiwi', 5],
300
+ ['cherry', 6],
301
+ ['date', 7],
302
+ ['fig', 8],
303
+ ['grape', 9],
304
+ ['lemon', 10],
305
+ ])
306
+ const result1 = encodeMap(map)
307
+ const result2 = encodeMap(map)
308
+
309
+ // Must be deterministic
310
+ expect(result1.hex).toBe(result2.hex)
311
+
312
+ // Verify canonical order: sorted by encoded key length first, then bytewise
313
+ // 3-char keys: fig, 4-char keys: date, kiwi, 5-char keys: apple, grape, lemon, mango
314
+ // 6-char keys: banana, cherry, zebra
315
+ const hex = result1.hex
316
+ const figPos = hex.indexOf('6366696703') // "fig" + integer 8 (but let's just check key order)
317
+ const datePos = hex.indexOf('6464617465') // "date"
318
+ const bananaPos = hex.indexOf('6662616e616e61') // "banana"
319
+
320
+ // fig (3 chars) should come before date (4 chars) should come before banana (6 chars)
321
+ expect(figPos).toBeLessThan(datePos)
322
+ expect(datePos).toBeLessThan(bananaPos)
323
+ })
324
+
325
+ it('should produce correct canonical order with mixed-type Map keys', () => {
326
+ const { encodeMap } = useCborCollectionEncoder({ canonical: true })
327
+
328
+ // Integer keys encode shorter than string keys
329
+ const map = new Map<EncodableValue, EncodableValue>([
330
+ ['z', 3],
331
+ [1, 1],
332
+ ['a', 2],
333
+ ])
334
+ const result = encodeMap(map)
335
+
336
+ // Integer 1 encodes as 0x01 (1 byte), "a" as 0x6161 (2 bytes), "z" as 0x617a (2 bytes)
337
+ // Canonical order: 1 (shortest), then "a" < "z" (same length, bytewise)
338
+ const hex = result.hex
339
+ // After map header (0xa3), first key should be integer 1 (0x01)
340
+ expect(hex.startsWith('a3')).toBe(true)
341
+ expect(hex.substring(2, 4)).toBe('01') // integer key 1
342
+ })
343
+
344
+ it('should reject duplicates when both canonical and rejectDuplicateKeys are on', () => {
345
+ const { encodeMap } = useCborCollectionEncoder({
346
+ canonical: true,
347
+ rejectDuplicateKeys: true
348
+ })
349
+
350
+ const map = new Map<EncodableValue, EncodableValue>([['x', 1]])
351
+ ;(map as any)[ALL_ENTRIES_SYMBOL] = [['x', 1], ['x', 2]]
352
+
353
+ expect(() => encodeMap(map))
354
+ .toThrow('Duplicate map key detected')
355
+ })
356
+
357
+ it('should not reject unique keys when both canonical and rejectDuplicateKeys are on', () => {
358
+ const { encodeMap } = useCborCollectionEncoder({
359
+ canonical: true,
360
+ rejectDuplicateKeys: true
361
+ })
362
+
363
+ const map = new Map<EncodableValue, EncodableValue>([
364
+ ['b', 2],
365
+ ['a', 1],
366
+ ['c', 3],
367
+ ])
368
+ const result = encodeMap(map)
369
+
370
+ // Should succeed and produce canonical order
371
+ expect(result.bytes[0]).toBe(0xa3) // 3 entries
372
+
373
+ // Keys in canonical order: a, b, c
374
+ const hex = result.hex
375
+ const aPos = hex.indexOf('6161') // "a"
376
+ const bPos = hex.indexOf('6162') // "b"
377
+ const cPos = hex.indexOf('6163') // "c"
378
+ expect(aPos).toBeLessThan(bPos)
379
+ expect(bPos).toBeLessThan(cPos)
380
+ })
381
+ })
288
382
  })
289
383
 
290
384
  describe('Depth limits', () => {
@@ -320,18 +414,22 @@ describe('CBOR Collection Encoder', () => {
320
414
  })
321
415
 
322
416
  describe('Output size limits', () => {
323
- it('should respect maxOutputSize option', () => {
417
+ it('should not enforce maxOutputSize at the collection level (enforced at root encoder)', () => {
418
+ // maxOutputSize is now checked at the root level in useCborEncoder,
419
+ // not inside the collection encoder. The collection encoder should
420
+ // encode without throwing, and the root encoder applies the check.
324
421
  const { encodeArray } = useCborCollectionEncoder({ maxOutputSize: 10 })
325
- const largeArray = Array(100).fill(1)
422
+ const smallArray = Array(20).fill(1)
326
423
 
327
- expect(() => encodeArray(largeArray))
328
- .toThrow('Encoded output exceeds maximum size')
424
+ // Collection encoder no longer throws on size - it just encodes
425
+ const result = encodeArray(smallArray)
426
+ expect(result.bytes.length).toBeGreaterThan(10)
329
427
  })
330
428
  })
331
429
 
332
430
  describe('Real-world Cardano examples', () => {
333
431
  it('should encode Cardano transaction structure', () => {
334
- const { encodeMap, encodeArray } = useCborCollectionEncoder()
432
+ const { encodeMap } = useCborCollectionEncoder()
335
433
 
336
434
  // Simplified Cardano transaction
337
435
  const tx = {
@@ -185,7 +185,7 @@ describe('CBOR Encoder Error Handling', () => {
185
185
 
186
186
  const largeArray = Array(100).fill(1)
187
187
 
188
- expect(() => encode(largeArray)).toThrow('Encoded output exceeds maximum size')
188
+ expect(() => encode(largeArray)).toThrow(/[Ee]ncoded output.*exceeds/)
189
189
  })
190
190
 
191
191
  it('should succeed when output is within maxOutputSize', () => {
@@ -205,6 +205,40 @@ describe('CBOR Encoder Error Handling', () => {
205
205
 
206
206
  expect(() => encode(largeMap)).toThrow('Encoded output exceeds maximum size')
207
207
  })
208
+
209
+ it('should enforce maxOutputSize on deeply nested structures whose total exceeds limit', () => {
210
+ // Total encoded size is 25 bytes, limit is 20.
211
+ const { encode } = useCborEncoder({ maxOutputSize: 20 })
212
+
213
+ const nested = [
214
+ [1, 2, 3, 4, 5],
215
+ [6, 7, 8, 9, 10],
216
+ [11, 12, 13, 14, 15],
217
+ [16, 17, 18, 19, 20],
218
+ ]
219
+
220
+ expect(() => encode(nested)).toThrow(/[Ee]ncoded output.*exceeds/)
221
+ })
222
+
223
+ it('should enforce maxOutputSize on nested maps whose total exceeds limit', () => {
224
+ // Total encoded size is 19 bytes, limit is 15.
225
+ const { encode } = useCborEncoder({ maxOutputSize: 15 })
226
+
227
+ const bigger = {
228
+ a: { x: 1, y: 2 },
229
+ b: { x: 3, y: 4 },
230
+ }
231
+
232
+ expect(() => encode(bigger)).toThrow(/[Ee]ncoded output.*exceeds/)
233
+ })
234
+
235
+ it('should include byte counts in the maxOutputSize error message', () => {
236
+ // After the fix, the root-level check should report actual size and limit.
237
+ // [1,2,3,4,5] encodes to 6 bytes; limit is 5.
238
+ const { encode } = useCborEncoder({ maxOutputSize: 5 })
239
+
240
+ expect(() => encode([1, 2, 3, 4, 5])).toThrow(/6 bytes exceeds limit of 5 bytes/)
241
+ })
208
242
  })
209
243
 
210
244
  describe('maxDepth as circular reference protection', () => {
@@ -540,13 +574,14 @@ describe('CBOR Canonical Encoding Validation', () => {
540
574
  expect(result.bytes.length).toBe(3)
541
575
  })
542
576
 
543
- it('should encode 1.5 as float32 (not float16)', () => {
577
+ it('should encode 1.5 as float16 (exact float16 representation)', () => {
544
578
  const { encode } = useCborEncoder()
545
579
  const result = encode(1.5)
546
580
 
547
- // 1.5 encodes as float32 due to float16 round-trip precision: 0xfa + 4 bytes
548
- expect(result.bytes[0]).toBe(0xfa)
549
- expect(result.bytes.length).toBe(5)
581
+ // 1.5 is exactly representable in float16: 0xf9 + 2 bytes
582
+ // float16 = 0 01111 1000000000 = 0x3e00
583
+ expect(result.bytes[0]).toBe(0xf9)
584
+ expect(result.bytes.length).toBe(3)
550
585
  })
551
586
 
552
587
  it('should encode 1.1 as float64 (no exact float16/32 representation)', () => {
@@ -96,6 +96,132 @@ describe('CBOR Simple Values and Floats Encoder', () => {
96
96
  expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x7e, 0x00]))
97
97
  expect(result.hex).toBe('f97e00')
98
98
  })
99
+
100
+ it('should encode 1.5 as float16', () => {
101
+ const { encodeFloat } = useCborSimpleEncoder()
102
+ const result = encodeFloat(1.5, 16)
103
+
104
+ // 1.5 = 0 01111 1000000000 = 0x3e00
105
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x3e, 0x00]))
106
+ expect(result.hex).toBe('f93e00')
107
+ })
108
+
109
+ it('should encode -2.0 as float16', () => {
110
+ const { encodeFloat } = useCborSimpleEncoder()
111
+ const result = encodeFloat(-2.0, 16)
112
+
113
+ // -2.0 = 1 10000 0000000000 = 0xc000
114
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0xc0, 0x00]))
115
+ expect(result.hex).toBe('f9c000')
116
+ })
117
+
118
+ it('should encode 0.5 as float16', () => {
119
+ const { encodeFloat } = useCborSimpleEncoder()
120
+ const result = encodeFloat(0.5, 16)
121
+
122
+ // 0.5 = 0 01110 0000000000 = 0x3800
123
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x38, 0x00]))
124
+ expect(result.hex).toBe('f93800')
125
+ })
126
+
127
+ it('should encode 65504 (max finite float16) as float16', () => {
128
+ const { encodeFloat } = useCborSimpleEncoder()
129
+ const result = encodeFloat(65504, 16)
130
+
131
+ // 65504 = 0 11110 1111111111 = 0x7bff
132
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x7b, 0xff]))
133
+ expect(result.hex).toBe('f97bff')
134
+ })
135
+ })
136
+
137
+ describe('Float16 IEEE 754 round-half-to-even', () => {
138
+ // Helper to construct a float64 value from raw mantissa bits
139
+ // exp64=0 means biased exponent=1023, so the value is 1.mantissa
140
+ function makeFloat64(exp64: number, mant64: number, sign = 0): number {
141
+ const biasedExp = BigInt(exp64 + 1023)
142
+ const bits = (BigInt(sign) << 63n) | (biasedExp << 52n) | BigInt(mant64)
143
+ const buf = new ArrayBuffer(8)
144
+ const view = new DataView(buf)
145
+ view.setBigUint64(0, bits, false)
146
+ return view.getFloat64(0, false)
147
+ }
148
+
149
+ it('should round down at midpoint when truncated mantissa is even (round-half-to-even)', () => {
150
+ const { encodeFloat } = useCborSimpleEncoder()
151
+ // Construct: mant16_truncated=256 (even), guard=1, round=0, sticky=0
152
+ // mant64 = (256 << 42) | (1 << 41)
153
+ const mant64 = 256 * Math.pow(2, 42) + Math.pow(2, 41)
154
+ const value = makeFloat64(0, mant64)
155
+ const result = encodeFloat(value, 16)
156
+
157
+ // Should round DOWN to mant16=256 (even), exp16=15
158
+ // float16 = 0 01111 0100000000 = 0x3d00
159
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x3d, 0x00]))
160
+ })
161
+
162
+ it('should round up at midpoint when truncated mantissa is odd (round-half-to-even)', () => {
163
+ const { encodeFloat } = useCborSimpleEncoder()
164
+ // Construct: mant16_truncated=257 (odd), guard=1, round=0, sticky=0
165
+ const mant64 = 257 * Math.pow(2, 42) + Math.pow(2, 41)
166
+ const value = makeFloat64(0, mant64)
167
+ const result = encodeFloat(value, 16)
168
+
169
+ // Should round UP to mant16=258 (even), exp16=15
170
+ // float16 = 0 01111 0100000010 = 0x3d02
171
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x3d, 0x02]))
172
+ })
173
+
174
+ it('should round up when above midpoint (guard=1, sticky bits set)', () => {
175
+ const { encodeFloat } = useCborSimpleEncoder()
176
+ // Construct: mant16_truncated=256 (even), guard=1, sticky=1 -> above midpoint
177
+ const mant64 = 256 * Math.pow(2, 42) + Math.pow(2, 41) + 1
178
+ const value = makeFloat64(0, mant64)
179
+ const result = encodeFloat(value, 16)
180
+
181
+ // Should round UP to mant16=257, exp16=15
182
+ // float16 = 0 01111 0100000001 = 0x3d01
183
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x3d, 0x01]))
184
+ })
185
+
186
+ it('should truncate when below midpoint (guard=0)', () => {
187
+ const { encodeFloat } = useCborSimpleEncoder()
188
+ // Construct: mant16_truncated=256, guard=0, some lower bits set
189
+ // mant64 = (256 << 42) | (1 << 40) -- only round bit, no guard
190
+ const mant64 = 256 * Math.pow(2, 42) + Math.pow(2, 40)
191
+ const value = makeFloat64(0, mant64)
192
+ const result = encodeFloat(value, 16)
193
+
194
+ // Should truncate to mant16=256, exp16=15
195
+ // float16 = 0 01111 0100000000 = 0x3d00
196
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x3d, 0x00]))
197
+ })
198
+
199
+ it('should handle mantissa overflow from rounding (bump exponent)', () => {
200
+ const { encodeFloat } = useCborSimpleEncoder()
201
+ // Construct: mant16_truncated=0x3FF (1023, odd), guard=1, sticky=1
202
+ // Rounding up gives 0x400, which overflows 10-bit mantissa
203
+ // Should become mant16=0, exp16+1
204
+ const mant64 = 0x3FF * Math.pow(2, 42) + Math.pow(2, 41) + 1
205
+ const value = makeFloat64(0, mant64)
206
+ const result = encodeFloat(value, 16)
207
+
208
+ // Mantissa overflows: exp16 bumps from 15 to 16, mant16=0
209
+ // float16 = 0 10000 0000000000 = 0x4000 = 2.0
210
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x40, 0x00]))
211
+ })
212
+
213
+ it('should handle mantissa overflow to infinity at max exponent', () => {
214
+ const { encodeFloat } = useCborSimpleEncoder()
215
+ // Construct: exp64=15 (exp16=30, max normal), mant16_truncated=0x3FF, guard=1, sticky=1
216
+ // Rounding overflows mantissa, bumps exp16 to 31 = infinity
217
+ const mant64 = 0x3FF * Math.pow(2, 42) + Math.pow(2, 41) + 1
218
+ const value = makeFloat64(15, mant64)
219
+ const result = encodeFloat(value, 16)
220
+
221
+ // exp16=30 + overflow = 31 (infinity), mant16=0
222
+ // float16 = 0 11111 0000000000 = 0x7c00 = +Infinity
223
+ expect(result.bytes).toEqual(new Uint8Array([0xf9, 0x7c, 0x00]))
224
+ })
99
225
  })
100
226
 
101
227
  describe('Float32 (single precision)', () => {
@@ -6,9 +6,10 @@
6
6
 
7
7
  import type { EncodeResult, EncodeOptions, EncodeContext, EncodableValue } from '../types'
8
8
  import { DEFAULT_ENCODE_OPTIONS, INDEFINITE_SYMBOL, ALL_ENTRIES_SYMBOL } from '../types'
9
- import { bytesToHex, concatenateUint8Arrays, compareBytes } from '../utils'
9
+ import { bytesToHex, concatenateUint8Arrays, compareMapKeys } from '../utils'
10
10
  import { useCborIntegerEncoder } from './useCborIntegerEncoder'
11
11
  import { useCborStringEncoder } from './useCborStringEncoder'
12
+ import { useCborSimpleEncoder } from './useCborSimpleEncoder'
12
13
  import { useCborByteString, useCborTextString } from '../../parser/composables/useCborStringTypes'
13
14
 
14
15
  interface CollectionEncodeOptions {
@@ -49,6 +50,7 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
49
50
  // Get other encoders
50
51
  const { encodeInteger } = useCborIntegerEncoder()
51
52
  const { encodeTextString, encodeByteString } = useCborStringEncoder(globalOptions)
53
+ const { encodeFloat } = useCborSimpleEncoder(options)
52
54
  const { isCborByteString } = useCborByteString()
53
55
  const { isCborTextString } = useCborTextString()
54
56
 
@@ -90,7 +92,16 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
90
92
  // true: 0xf5, false: 0xf4
91
93
  return new Uint8Array([value ? 0xf5 : 0xf4])
92
94
  }
93
- else if (typeof value === 'number' || typeof value === 'bigint') {
95
+ else if (typeof value === 'number') {
96
+ if (Object.is(value, -0)) {
97
+ return encodeFloat(value, 16).bytes
98
+ }
99
+ if (Number.isInteger(value) && Number.isSafeInteger(value)) {
100
+ return encodeInteger(value).bytes
101
+ }
102
+ return encodeFloat(value).bytes
103
+ }
104
+ else if (typeof value === 'bigint') {
94
105
  return encodeInteger(value).bytes
95
106
  }
96
107
  else if (typeof value === 'string' || isCborTextString(value)) {
@@ -211,12 +222,6 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
211
222
 
212
223
  const result = concatenateUint8Arrays(parts)
213
224
 
214
- // Check output size
215
- ctx.bytesWritten += result.length
216
- if (ctx.bytesWritten > ctx.options.maxOutputSize) {
217
- throw new Error('Encoded output exceeds maximum size')
218
- }
219
-
220
225
  return {
221
226
  bytes: result,
222
227
  hex: bytesToHex(result)
@@ -251,7 +256,6 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
251
256
  // Definite-length encoding
252
257
  const ctx: EncodeContext = {
253
258
  depth: 0,
254
- bytesWritten: 0,
255
259
  options
256
260
  }
257
261
 
@@ -276,7 +280,6 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
276
280
 
277
281
  const ctx: EncodeContext = {
278
282
  depth: 0,
279
- bytesWritten: 0,
280
283
  options
281
284
  }
282
285
 
@@ -316,19 +319,28 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
316
319
  : Object.entries(map)
317
320
  }
318
321
 
319
- // In canonical mode, sort entries by encoded key (unless using allEntries for byte-perfect)
322
+ // Pre-encode keys once for canonical sorting and/or duplicate detection
323
+ // This avoids re-encoding keys O(N log N) times inside the sort comparator
324
+ let preEncodedKeys: Uint8Array[] | null = null
325
+
320
326
  if (ctx.options.canonical && !(map as any)[ALL_ENTRIES_SYMBOL]) {
321
- entries.sort((a, b) => {
322
- const keyA = encodeValue(a[0], { ...ctx, depth: ctx.depth + 1 })
323
- const keyB = encodeValue(b[0], { ...ctx, depth: ctx.depth + 1 })
324
- return compareBytes(keyA, keyB)
325
- })
327
+ const childCtx = { ...ctx, depth: ctx.depth + 1 }
328
+ const withEncodedKeys = entries.map(entry => ({
329
+ encodedKey: encodeValue(entry[0], childCtx),
330
+ entry
331
+ }))
332
+ const keyOrder = ctx.options.mapKeyOrder ?? 'length-first'
333
+ withEncodedKeys.sort((a, b) => compareMapKeys(a.encodedKey, b.encodedKey, keyOrder))
334
+ entries = withEncodedKeys.map(t => t.entry)
335
+ preEncodedKeys = withEncodedKeys.map(t => t.encodedKey)
326
336
  }
327
337
 
328
338
  if (ctx.options.rejectDuplicateKeys) {
329
339
  const seen = new Set<string>()
330
- for (const [key] of entries) {
331
- const keyBytes = encodeValue(key, { ...ctx, depth: ctx.depth + 1, bytesWritten: 0 })
340
+ // Reuse pre-encoded keys if available (from canonical sort above)
341
+ const keysForDupCheck = preEncodedKeys
342
+ ?? entries.map(e => encodeValue(e[0], { ...ctx, depth: ctx.depth + 1 }))
343
+ for (const keyBytes of keysForDupCheck) {
332
344
  const keyHex = bytesToHex(keyBytes)
333
345
  if (seen.has(keyHex)) {
334
346
  throw new Error('Duplicate map key detected')
@@ -350,12 +362,6 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
350
362
 
351
363
  const result = concatenateUint8Arrays(parts)
352
364
 
353
- // Check output size
354
- ctx.bytesWritten += result.length
355
- if (ctx.bytesWritten > ctx.options.maxOutputSize) {
356
- throw new Error('Encoded output exceeds maximum size')
357
- }
358
-
359
365
  return {
360
366
  bytes: result,
361
367
  hex: bytesToHex(result)
@@ -390,7 +396,6 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
390
396
  // Definite-length encoding
391
397
  const ctx: EncodeContext = {
392
398
  depth: 0,
393
- bytesWritten: 0,
394
399
  options
395
400
  }
396
401
 
@@ -422,7 +427,6 @@ export function useCborCollectionEncoder(globalOptions?: Partial<EncodeOptions>)
422
427
 
423
428
  const ctx: EncodeContext = {
424
429
  depth: 0,
425
- bytesWritten: 0,
426
430
  options
427
431
  }
428
432
 
@@ -75,6 +75,43 @@ export function useCborEncoder(globalOptions?: Partial<EncodeOptions>) {
75
75
  * @throws Error if value type is unsupported
76
76
  */
77
77
  const encode = (value: EncodableValue): EncodeResult => {
78
+ const result = encodeValue(value)
79
+
80
+ // Enforce maxOutputSize at the root level.
81
+ // This is the single authoritative check — collection/string encoders no longer
82
+ // track bytesWritten individually, which was broken for nested structures.
83
+ if (options.maxOutputSize && result.bytes.length > options.maxOutputSize) {
84
+ throw new Error(
85
+ `Encoded output size ${result.bytes.length} bytes exceeds limit of ${options.maxOutputSize} bytes`
86
+ )
87
+ }
88
+
89
+ return result
90
+ }
91
+
92
+ // Recursion depth tracked across ALL value types — including the tag boundary.
93
+ // Tagged values re-enter through this same encode()/encodeValue() pair, so
94
+ // counting here (rather than only inside the collection encoder) prevents a
95
+ // deeply nested {tag,value} chain from silently bypassing maxDepth and
96
+ // overflowing the call stack.
97
+ let currentDepth = 0
98
+
99
+ /**
100
+ * Encode any JavaScript value to CBOR (internal, no size check)
101
+ */
102
+ const encodeValue = (value: EncodableValue): EncodeResult => {
103
+ if (currentDepth > options.maxDepth) {
104
+ throw new Error(`Maximum nesting depth ${options.maxDepth} exceeded`)
105
+ }
106
+ currentDepth++
107
+ try {
108
+ return encodeValueInner(value)
109
+ } finally {
110
+ currentDepth--
111
+ }
112
+ }
113
+
114
+ const encodeValueInner = (value: EncodableValue): EncodeResult => {
78
115
  // Handle null/undefined/boolean
79
116
  if (value === null || value === undefined || typeof value === 'boolean') {
80
117
  return encodeSimple(value)
@@ -82,6 +119,9 @@ export function useCborEncoder(globalOptions?: Partial<EncodeOptions>) {
82
119
 
83
120
  // Handle numbers
84
121
  if (typeof value === 'number') {
122
+ if (Object.is(value, -0)) {
123
+ return encodeFloat(value, 16)
124
+ }
85
125
  // Check if it's an integer
86
126
  if (Number.isSafeInteger(value)) {
87
127
  return encodeInteger(value)
@@ -68,8 +68,8 @@ export function useCborSimpleEncoder(_globalOptions?: Partial<EncodeOptions>) {
68
68
  */
69
69
  const encodeFloat16Bytes = (value: number): Uint8Array => {
70
70
  // Handle special cases
71
+ if (Object.is(value, -0)) return new Uint8Array([0x80, 0x00])
71
72
  if (value === 0) return new Uint8Array([0x00, 0x00])
72
- if (value === -0) return new Uint8Array([0x80, 0x00])
73
73
  if (Number.isNaN(value)) return new Uint8Array([0x7e, 0x00])
74
74
  if (value === Infinity) return new Uint8Array([0x7c, 0x00])
75
75
  if (value === -Infinity) return new Uint8Array([0xfc, 0x00])
@@ -88,6 +88,9 @@ export function useCborSimpleEncoder(_globalOptions?: Partial<EncodeOptions>) {
88
88
  const mant64 = Number(bits & 0xfffffffffffffn)
89
89
 
90
90
  // Convert to float16 range
91
+ // Use BigInt for bit manipulation since mant64 can exceed 32 bits,
92
+ // which would cause JavaScript's bitwise operators to truncate silently.
93
+ const mant64n = BigInt(mant64)
91
94
  let exp16: number
92
95
  let mant16: number
93
96
 
@@ -99,16 +102,40 @@ export function useCborSimpleEncoder(_globalOptions?: Partial<EncodeOptions>) {
99
102
  // Subnormal float16: shift the implicit 1.mantissa into the fraction bits
100
103
  exp16 = 0
101
104
  const shift = -14 - exp64
102
- mant16 = (((1 << 10) | (mant64 >> 42)) + ((1 << (shift - 1)) - 1)) >> shift
105
+ // Build the full subnormal mantissa: implicit 1 bit + top 10 mantissa bits,
106
+ // then apply IEEE 754 round-half-to-even before shifting into subnormal position.
107
+ // The 11-bit value (1.mant10) needs to be right-shifted by 'shift' to get the
108
+ // subnormal mantissa. We apply rounding at the shift boundary.
109
+ const fullMant = (1n << 10n) | (mant64n >> 42n)
110
+ // Bits lost from the float64 mantissa during the >> 42 extraction
111
+ const lostFromExtract = mant64n & ((1n << 42n) - 1n)
112
+ // The 'shift' additional bits lost when converting to subnormal
113
+ const shiftN = BigInt(shift)
114
+ const guardBit = Number((fullMant >> (shiftN - 1n)) & 1n)
115
+ // Sticky includes bits below guard in fullMant plus any bits lost from extraction
116
+ const stickyBitsBelow = shiftN > 1n ? (fullMant & ((1n << (shiftN - 1n)) - 1n)) : 0n
117
+ const sticky = (stickyBitsBelow !== 0n || lostFromExtract !== 0n) ? 1 : 0
118
+ const truncated = Number(fullMant >> shiftN)
119
+ const lsb = truncated & 1
120
+ mant16 = truncated + (guardBit & (sticky | lsb))
103
121
  } else if (exp64 > 15) {
104
122
  // Overflow to infinity
105
123
  exp16 = 31
106
124
  mant16 = 0
107
125
  } else {
108
- // Normal number
126
+ // Normal number: IEEE 754 round-half-to-even (guard/round/sticky)
109
127
  exp16 = exp64 + 15
110
- // Take top 10 bits of mantissa
111
- mant16 = mant64 >> 42
128
+ const truncated = Number(mant64n >> 42n)
129
+ const guardBit = Number((mant64n >> 41n) & 1n)
130
+ const stickyBits = (mant64n & ((1n << 41n) - 1n)) !== 0n ? 1 : 0
131
+ const lsb = truncated & 1 // least significant bit of truncated result
132
+ mant16 = truncated + (guardBit & (stickyBits | lsb))
133
+ // Handle mantissa overflow from rounding (0x3FF + 1 = 0x400 bumps exponent)
134
+ if (mant16 > 0x3ff) {
135
+ mant16 = 0
136
+ exp16 += 1
137
+ // If exp16 overflows to 31 (0x1f), it becomes infinity -- correct IEEE 754 behavior
138
+ }
112
139
  }
113
140
 
114
141
  const float16 = (sign << 15) | (exp16 << 10) | mant16
@@ -155,13 +182,17 @@ export function useCborSimpleEncoder(_globalOptions?: Partial<EncodeOptions>) {
155
182
  */
156
183
  const canBeFloat16 = (value: number): boolean => {
157
184
  // Special values
158
- if (!Number.isFinite(value) || value === 0 || value === -0) {
185
+ if (!Number.isFinite(value) || Object.is(value, 0) || Object.is(value, -0)) {
159
186
  return true
160
187
  }
161
188
 
162
- // Check range
189
+ // Check range. The lower bound is the smallest positive float16 SUBNORMAL
190
+ // (2^-24 ≈ 5.96e-8), NOT the smallest normal (2^-14). Using 2^-14 here
191
+ // previously made the encoder emit float32 for representable subnormals,
192
+ // producing output its own canonical decoder (fitsInFloat16) then rejected.
193
+ // The round-trip equality check below is the real precision gate.
163
194
  const absValue = Math.abs(value)
164
- if (absValue < 0.00006103515625 || absValue > 65504) {
195
+ if (absValue < 5.960464477539063e-8 || absValue > 65504) {
165
196
  return false
166
197
  }
167
198
 
@@ -195,7 +226,7 @@ export function useCborSimpleEncoder(_globalOptions?: Partial<EncodeOptions>) {
195
226
  */
196
227
  const canBeFloat32 = (value: number): boolean => {
197
228
  // Special values
198
- if (!Number.isFinite(value) || value === 0 || value === -0) {
229
+ if (!Number.isFinite(value) || Object.is(value, 0) || Object.is(value, -0)) {
199
230
  return true
200
231
  }
201
232
 
@@ -3,12 +3,12 @@
3
3
  * Following RFC 8949 specification
4
4
  */
5
5
 
6
- import type { PlutusConstr, CborByteString, CborTextString } from '../parser/types'
6
+ import type { PlutusConstr, CborByteString, CborTextString, MapKeyOrder } from '../parser/types'
7
7
  import { INDEFINITE_SYMBOL, ALL_ENTRIES_SYMBOL } from '../parser/types'
8
8
 
9
9
  // Re-export symbols and types for use in encoder
10
10
  export { INDEFINITE_SYMBOL, ALL_ENTRIES_SYMBOL }
11
- export type { CborByteString, CborTextString }
11
+ export type { CborByteString, CborTextString, MapKeyOrder }
12
12
 
13
13
  /**
14
14
  * Encoder options for controlling behavior
@@ -20,6 +20,12 @@ export interface EncodeOptions {
20
20
  allowIndefinite?: boolean
21
21
  /** Reject duplicate map keys */
22
22
  rejectDuplicateKeys?: boolean
23
+ /**
24
+ * Map key ordering used in canonical mode.
25
+ * Defaults to 'length-first' (Cardano CIP-21 / RFC 7049 §3.9).
26
+ * Use 'bytewise' for RFC 8949 §4.2.1 core deterministic order.
27
+ */
28
+ mapKeyOrder?: MapKeyOrder
23
29
  /** Maximum nesting depth */
24
30
  maxDepth?: number
25
31
  /** Maximum output size in bytes */
@@ -33,6 +39,7 @@ export const DEFAULT_ENCODE_OPTIONS: Required<EncodeOptions> = {
33
39
  canonical: false,
34
40
  allowIndefinite: true,
35
41
  rejectDuplicateKeys: false,
42
+ mapKeyOrder: 'length-first',
36
43
  maxDepth: 64,
37
44
  maxOutputSize: 100 * 1024 * 1024 // 100 MB
38
45
  }
@@ -81,8 +88,6 @@ export interface TaggedValue {
81
88
  export interface EncodeContext {
82
89
  /** Current nesting depth */
83
90
  depth: number
84
- /** Bytes written so far */
85
- bytesWritten: number
86
91
  /** Encoder options */
87
92
  options: Required<EncodeOptions>
88
93
  }